The first time someone asked me to "modernize" a Laravel app, the codebase was on Laravel 5.6, PHP 7.2, and a vendor/ directory with eight transitive dependencies that no longer existed on Packagist. There was no test suite. The deploy script was a git pull over SSH on a single VPS. The team's question was the question I get every couple of years: "can we just upgrade to the current version in a sprint?"
You can't. Or rather, you can, and you'll spend the next six months fixing the bugs you introduced. Upgrading Laravel works — the framework's upgrade story is one of the better ones in the PHP world — but it works incrementally, with a baseline you trust, one minor version at a time. This is what that looks like in practice.
The Path: 5 → 6 → 7 → 8 → 9 → 10 → 11
Laravel ships a major version roughly once a year, and most majors have a small, well-documented set of breaking changes. The shape of the road from a Laravel 5.x app to current:
- 5.5 → 5.8 — small. Mostly notification/queue API tweaks, deprecated facade aliases. Easy.
- 5.8 → 6 (LTS) — the big bookkeeping move: PSR-4 namespace cleanup,
str_*andarray_*global helpers extracted to packages,Job::classevent signature change. Laravel 6 is an LTS — a sane parking spot if you have to pause. - 6 → 7 —
CarbonInterval/ blade component changes, HTTP client released. Mostly additive. - 7 → 8 — the Jobs/Events refactor (
use Illuminate\Foundation\Bus\Dispatchable), factory classes (UserFactory extends Factory), the routing namespace removal. This is the one where teams get stuck. - 8 → 9 — Symfony 6 (PHP 8.0 minimum), Flysystem 3, anonymous migrations, route caching changes, the
Route::resource('...')parameter change. - 9 → 10 — PHP 8.1+, native types throughout the framework,
Processfacade,dispatchSynccleanup,assertJsonPathbecoming stricter. - 10 → 11 — the slim skeleton: no
app/Http/Kernel.php(middleware moves tobootstrap/app.php), noapp/Console/Kernel.php, noapp/Exceptions/Handler.php.install:apiandinstall:broadcastingArtisan commands. PHP 8.2+. The structural change is the one to read for carefully — your custom middleware, exception handling, and command registration all move. - 11 → 12 — small: starter kit overhaul (Inertia/React/Vue/Livewire), Carbon 3, no removal of major APIs.
The right mental model: you are not going from "5.6 to 11." You are going from 5.6 to 5.8, then 5.8 to 6, then 6 to 7, and so on, with a deploy and a soak in between. The compounding risk of skipping minors is what burns weekends.
Step Zero: A Baseline Test Suite
The single most useful thing you can do before touching composer.json is buy yourself observability. If the project has no tests:
- Pick the ten endpoints that earn the company's money. Login, signup, the checkout, the dashboard, the export. Write a feature test per endpoint that hits it as a real user and asserts a 2xx and a stable shape. This is not unit-testing; this is "did we still ship a working app."
- Add a smoke test for each queued job that matters. Dispatch it synchronously, assert the side effect (DB row, email queued, S3 file written).
- Wire CI. GitHub Actions running
php artisan teston every push, with a green/red badge on PRs.
Without that scaffolding, you cannot tell whether an upgrade broke the app or whether the app was already broken. It will take a few days. Spend them.
A second trick: tag the production version of every dependency in a composer.lock you commit, then run composer outdated --direct to see the gap. That output is your roadmap.
The Tools That Actually Help
You do not have to do this by hand for every file. Two tools earn their cost:
Laravel Shift is a paid service ($9 per Laravel version per repo, more for the deeper packs) that runs the Laravel team's automated upgrade scripts on your codebase and opens a pull request. It handles the boring 80%: namespace updates, deprecated method swaps, config file diffs, framework files moves. The PR is then a normal review — you look at every change, run your tests, and merge. For most upgrades I do, Shift produces 200 changed files and the human work is checking 30 of them.
Rector is free, runs locally, and is far more powerful but more dangerous. The RectorLaravel rule sets handle PHP-version migrations (AddTypeDeclarationRector for return types, etc.) and Laravel-specific transformations. Pin the exact rule sets you trust and run them in narrow PRs:
// rector.php
use Rector\Config\RectorConfig;
use RectorLaravel\Set\LaravelLevelSetList;
use RectorLaravel\Set\LaravelSetList;
return RectorConfig::configure()
->withPaths([__DIR__ . '/app', __DIR__ . '/tests'])
->withSets([
LaravelLevelSetList::UP_TO_LARAVEL_100,
LaravelSetList::LARAVEL_CODE_QUALITY,
])
->withImportNames();
Rector is at its most useful for the cross-cutting changes: native types on every method, replacing app('foo') with constructor injection, removing facade aliases. Run it on a branch, eyeball the diff, ship.
The Breaking Changes That Actually Bite
Most upgrade pain across Laravel versions clusters in a handful of areas:
Routing. Laravel 8 removed the auto-prefixed App\Http\Controllers namespace. Routes that read Route::get('/users', 'UserController@index') need to change to Route::get('/users', [UserController::class, 'index']). Shift handles this; double-check routes/api.php for stragglers.
Factories. Laravel 8 moved factories from closures to classes. The compatibility package (laravel/legacy-factories) keeps the old syntax working, but it's a crutch. Convert your factories during the 8.x window.
Jobs and events. The ShouldQueue interface and Dispatchable trait got firmer in 8/9. Hand-rolled job dispatchers from 5.x rarely make the trip cleanly; rewrite them with dispatch(new MyJob()) or MyJob::dispatch().
Migrations. Laravel 9 made anonymous migrations the default. Old migrations with class names still work; new ones can omit them. There's no forced rewrite, but new migrations should follow the modern shape.
Symfony version. Laravel 9 pulled Symfony 6 (PHP 8.0+), 10 stayed on Symfony 6 (PHP 8.1+), 11 moved to Symfony 7 (PHP 8.2+). Most pain here is in third-party packages that haven't kept up. Run composer why-not laravel/framework:^11 and resolve transitive constraints before you start.
The slim skeleton (11). Custom middleware that lived in app/Http/Kernel.php moves into bootstrap/app.php's withMiddleware() callback. Exception handling moves to withExceptions(). Console commands are auto-registered. The Shift PR for this one is bigger than usual; read every change.
Modernise The Code While You're There
A version bump is also when teams pick up bad habits the framework has outgrown. The cheap wins:
- Replace
app('foo'),resolve('foo'), and facade calls inside services with constructor injection. A typed constructor argument is faster, testable, and PhpStan-friendly. - Drop facade aliases.
\Carbon\Carbon::now()is fine; the globalCarbon::now()alias is a drag oncomposer dumpautoloadand dies on namespacing edge cases. - Add native types to every method. Rector's
AddReturnTypeDeclarationFromYieldsRectorplusReturnTypeFromStrictNativeCallRectorwill get you most of the way for free. - Install
barryvdh/laravel-ide-helperonce and runphp artisan ide-helper:generate && php artisan ide-helper:modelsafter every dependency update. Your editor will start finding the bugs the upgrade introduced. - Run
composer audit(Composer 2.4+) and fix the reds before merging. Old apps usually carry one or two CVEs in dependencies. - Turn on PHPStan via
larastan, even at level 0. You will find dead code and silent type confusion that no test would have caught.
These are not migration tasks. They are the work that makes the next upgrade cheaper.
How To Ship Without Freezing The Roadmap
The hardest part of an upgrade is not the code. It's convincing the business to pay for a quarter of "no new features." The honest pitch I make:
- Each minor version is its own PR, branched from
main, merged after CI is green and a manual smoke test passes. No long-lived "upgrade branch" that drifts. - Feature work continues on
main. Rebase the upgrade PR weekly. If the conflicts are manageable, that's a sign you're on the right path; if they aren't, the upgrade is too big and needs to be cut smaller. - One deploy per minor. You ship to production, watch error rates and Sentry for a day, then start the next minor. Twelve deploys in a month is not crazy — that's three minors with a soak each.
- Plan for the deprecation list, not the version number. "Get to 11 by Q3" is a deadline that breeds shortcuts. "Remove the last facade alias by Q1, replace factories by Q2, land Symfony 7 by Q3" is a list of small wins that also gets you to 11.
The teams that do this well never have a "we're upgrading Laravel" quarter. They have a composer outdated --direct output that's always close to clean, because they upgrade the framework like they upgrade everything else — boringly, in small commits, with the same review process the rest of the codebase gets.
A One-Sentence Mental Model
Migrating an old Laravel app is a sequence of small, well-documented steps from 5.x through 11.x — pinned by a baseline test suite, automated by Laravel Shift and Rector for the boring 80%, soaked one minor at a time on production, and used as the moment to retire facade aliases, install native types, and turn on PHPStan so the next upgrade is cheaper than this one.




