Upgrading Laravel 9 to 13, One Version at a Time
Laravel ships a major version each year, and each one comes with an upgrade guide written for the version immediately before it. That's the structure the whole process depends on: the documentation exists for 9 to 10, 10 to 11, 11 to 12, and 12 to 13, and nothing exists for 9 to 13.
So the sequence is four separate jobs, each with a working, committed application at the end.
Why one hop at a time
Composer won't stop you jumping. Set laravel/framework to ^13.0, resolve whatever fights break out among your other dependencies, and you'll eventually get an install.
Then you're debugging four versions of breaking changes simultaneously, with no way to attribute a failure to the change that caused it. Some of them interact — a column modification that quietly loses an attribute in 11 produces a schema that behaves oddly in 13 for reasons the 13 guide has nothing to say about.
One at a time, each hop is a guide you can follow line by line, a test run, and a commit. If something breaks two hops later you have a bisectable history rather than one enormous change.
Hop 1: Laravel 9 to 10
Laravel's own estimate: 10 minutes. Requires PHP 8.1 and Composer 2.2.
This is the gentlest of the four. The high-impact items are dependency bumps and setting minimum-stability back to stable.
What actually catches applications:
- The
$datesproperty on models is gone. It was deprecated for years and this is where it's removed. Move the columns into$castswith'datetime'. DB::rawexpressions can no longer be cast to string. If your code does(string) $expressionor calls__toString, usegetValue($grammar)instead. Most applications never touch this.- Monolog 3. If you interact with Monolog directly, or use a third-party logging service, those packages need versions that support it.
MocksApplicationServicesis removed, takingexpectsEvents,expectsJobsandexpectsNotificationswith it. Replace withEvent::fake(),Bus::fake()andNotification::fake(). This one only hits your test suite, which is the good kind of breakage.
Hop 2: Laravel 10 to 11
Laravel's own estimate: 15 minutes. Requires PHP 8.2, curl 7.34, and SQLite 3.26 if you use SQLite.
This is the hop that costs real time, and two of its items deserve a proper look.
Column modifications now drop anything you don't restate. This is the one that quietly changes your database. In Laravel 10, changing a column kept its other attributes. In 11, every modifier you want to keep has to be named again:
// Laravel 10: keeps unsigned, default and comment
$table->integer('votes')->nullable()->change();
// Laravel 11: they are dropped unless you restate them
$table->integer('votes')
->unsigned()
->default(1)
->comment('The vote count')
->nullable()
->change();
Every ->change() in your migration history is a candidate. Laravel's own suggestion, if auditing them all is impractical, is to squash the history with php artisan schema:dump so the schema file becomes the starting point.
First-party packages stop shipping their own migrations. Cashier, Passport, Sanctum, Spark and Telescope no longer load migrations from their own directories, so you publish them into your application:
php artisan vendor:publish --tag=sanctum-migrations
Miss this and the tables those packages need stop being created on a fresh install — which typically surfaces in CI or on a new developer's machine, some time after you thought the upgrade was finished.
The rest of the hop:
- Floating-point column types were rewritten.
doubleandfloatno longer take total and places;unsignedDecimal,unsignedDoubleandunsignedFloatare gone. - Sanctum 4, and major bumps across the first-party packages. Sanctum also needs middleware references updated in its config.
- Doctrine DBAL is no longer a dependency and its associated methods are removed. Laravel's native schema methods replace them.
- Carbon 3 is supported alongside Carbon 2 here. If you move to 3,
diffIn*methods return floats and can be negative, which is a behavior change that won't announce itself — it'll just make a duration wrong somewhere. - Rate limiting moved from minutes to seconds internally. Documented usage through
Limit::perMinuteis unaffected; manual construction ofLimitobjects is not.
One thing you can skip: Laravel 11 introduced a slimmer application skeleton, and Laravel's guidance is explicitly not to migrate your structure to it. Laravel 11 supports the Laravel 10 layout deliberately. Restructuring is an optional project, and mixing it into an upgrade is how you lose the ability to tell which change broke something.
Hop 3: Laravel 11 to 12
Laravel's own estimate: 5 minutes. The quietest of the four.
HasUuidsnow produces UUIDv7 instead of ordered v4. If your IDs need to stay v4, switch the trait toHasVersion4Uuids. Existing rows are untouched; new ones change shape.- Carbon 2 support is removed. If you deferred the Carbon 3 decision during hop 2, it lands here.
- The
imagevalidation rule no longer accepts SVGs. Sensible default, since SVGs can carry script. If you accept them deliberately, say so:image:allow_svg. - The
localfilesystem disk defaults tostorage/app/privateinstead ofstorage/app, if you never defined the disk explicitly. Check it before it changes where your files land.
Hop 4: Laravel 12 to 13
Laravel's own estimate: 10 minutes. Requires PHP 8.3.
Laravel say they focused this cycle on minimizing breaking changes, and the guide bears that out — one high-impact item beyond dependency bumps.
The CSRF middleware is renamed. VerifyCsrfToken becomes PreventRequestForgery, which also adds origin verification using the Sec-Fetch-Site header. The old names remain as deprecated aliases, so this doesn't break at runtime, but direct references should be updated — particularly withoutMiddleware([VerifyCsrfToken::class]) in tests.
Two more that are easy to trip over:
serializable_classesin the cache config now defaults tofalse, hardening against deserialization attacks if yourAPP_KEYleaks. If you deliberately cache PHP objects, list the classes explicitly or move to arrays.- Session serialization. The Laravel 13 skeleton sets session
serializationtojson. If you sync your config files to the new skeleton, changing that value fromphptojsoninvalidates every active session — everyone signed in gets logged out. Fine if you plan it, alarming on a Monday morning if you don't.
About those time estimates
Five to fifteen minutes per hop, and they're honest — for what they measure. They describe the framework's breaking changes on a stock application.
They don't cover the three things that actually take the time:
Your dependencies. Every package you use needs a version compatible with the target release, and one abandoned package with no compatible version can block the whole hop. Check this before you start, not after.
Your own code. The guide tells you what changed in the framework, not which of your files depend on the old behavior.
Verifying it still works. This dominates everything, and how long it takes is decided by whether you have tests.
That last point deserves bluntness. With a decent suite, an upgrade is: change the constraint, run the tests, fix what's red, ship. Without one, it's a manual regression test of your entire application performed by someone who has to guess what the application does. If you have no tests and you're four versions behind, writing tests for your critical paths first isn't a detour — it's the thing that makes the rest possible.
The order of operations
For each hop: check package compatibility, upgrade PHP if this hop requires it, bump the framework and dependencies, work through the guide, run the tests, click through the parts no test covers, commit.
Then do the next one.
Getting current is the goal, and staying current is the point. Once you're on 13, the following year's hop is one documented step from where you already are — which is a very different thing from the four you just did. Where you stand today has the support dates to plan the next one against.
We do Laravel upgrades on custom applications and inherited web systems, including ones several versions behind with no tests to fall back on. If you want to know what getting current would take, tell us what you're running.
Frequently asked questions
Can I jump straight from Laravel 9 to Laravel 13?
Composer will let you try, and you will be debugging four versions' worth of breaking changes at once with no way to tell which one caused what. Going one version at a time means each step has a guide written for exactly that transition, and a working application at the end of it you can commit before moving on.
How long does a Laravel upgrade take in practice?
For an application that is one version behind, well maintained, and has tests, a day is realistic. For one four versions behind with heavy package use and no tests, plan in weeks — most of which goes on dependencies and verifying behavior rather than on the framework itself.
What is Laravel Shift?
A commercial service that automates much of the mechanical work of an upgrade, and it is referenced in Laravel's own upgrade guides. We have no relationship with it and no stake in whether you use it. It does not remove the need to test the result.