Anybody who has just finished a Symfony upgrade and then raises a Laravel project has an uncomfortable experience: the tools they got used to do not bite. There is no log listing the work, and a clean test run says less about the state than you would assume.
That is not a lack of care on Laravel's part, it follows from how the framework is built. A lot of what is explicit configuration in Symfony is convention in Laravel, and conventions change without any call that could report itself as deprecated.
This article describes four places where Laravel upgrades have pitfalls of their own, and how to find them before the jump rather than after it.
Why the familiar tools do not bite
Symfony promises a stable public interface within a major version and marks everything that disappears as deprecated beforehand. That produces a list you work through.
Laravel works differently. There is an upgrade guide per major version, very decently maintained, and there are deprecations, but they cover a smaller share. The reason lies in three properties of the framework that are advantages day to day and turn into work at upgrade time.
Behaviour lives in conventions. How a model name maps to a table name, how a value is cast when read, in what order things run: that is behaviour without a call. When it changes, nothing reports it, and the effect is a different value, not an error.
A lot is resolved at runtime. Facades, dynamic properties, magic methods. Static analysis sees less of that than in a Symfony project, and that same analysis is what produces most of the findings in Symfony.
The project skeleton is part of the application. Configuration files, the bootstrap file, exception handling: all of it lives in the project, was copied once at creation, and has been drifting quietly from the current version ever since.
The planning consequence: a Laravel upgrade is less list work and more comparison. That is not worse, it is different, and planning it like a Symfony upgrade produces a wrong number.
Pitfall 1: Eloquent does something else without saying so
The most expensive finding is almost always in data access, and not as an error but as a different value.
Three patterns turn up regularly.
Casts on read. A field declared as float, a date format rendered differently in one version than the next, a boolean coming out of a CHAR(1) column. The result travels into an output or an interface, and it surfaces there weeks later.
Events on save. Whether a mass update fires model events, whether an event lands in a queue, whether a relation is saved along with its parent: those details have changed several times across versions. Where business logic hangs off an event, it then runs twice or not at all.
Relations and when they load. Changes to when a relation is loaded produce no error, they produce a different number of queries. The application works and gets slower, and after two weeks the connection to the upgrade is no longer visible.
The protection against that is a golden master, at the level that matters: not on individual methods, but on the outputs the business sees.
// Run before the upgrade, commit the result.
// Run again after: every difference is a finding.
public function testCustomerPayloadUnchanged(): void
{
$customers = Customer::with(['contracts', 'addresses'])
->whereIn('id', [17, 402, 1337, 88231]) // deliberately edge cases
->get()
->map(fn (Customer $c) => $c->toArray())
->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
$this->assertMatchesJsonSnapshot($customers);
}The four identifiers in the example are the point: records with edge cases, not the first four in the table. A customer with no address, one with a cancelled contract, one with non-ASCII characters in the name, one from before a data migration.
The comparison is worth making: in Symfony the same work sits somewhere else, namely in the deprecation log. Working through Symfony deprecations without drowning in the log.
Pitfall 2: the package ecosystem
Laravel has a large ecosystem, and a substantial part of it is tied to major versions: a package supports Laravel 10 and 11 but not 12, and arrives three months later or never.
From that follows the most important planning step, and it comes before any estimate:
# Who blocks the target version? One line, a complete answer.
composer why-not laravel/framework 12.0
# And the abandoned packages separately, they are the expensive part
composer show --direct --format=json \
| php -r '$d=json_decode(file_get_contents("php://stdin"),true);
foreach($d["installed"] as $p) if(!empty($p["abandoned"]))
echo $p["name"], PHP_EOL;'The list sorts into three groups with very different answers. A package that will catch up costs waiting time. A package with an accepted successor costs a rebuild in a few places. An abandoned package with no successor costs in-house work, and that is the item that determines how long the project runs.
An observation from practice that saves time: packages that only provide convenience, a few helper methods or a nicer facade, tend to get removed at upgrade time rather than replaced. Replacing them usually costs more than doing without.
Pitfall 3: the front-end chain
The chain of Blade, translation files, the asset build tool and published assets is where upgrades fail most visibly, because a mistake there is immediately seen by everybody.
Three points belong on the list before anybody estimates.
The asset build tool. Changing the tool that builds front-end files is not purely a Laravel matter and is part of the upgrade regardless. It affects configuration, the includes in templates and the build step in the pipeline. It can be done separately from the framework jump, and that is exactly what I recommend: front-end chain first, framework second, because otherwise every failure has two possible causes.
Published package assets. Files a package once copied into the project and that have been modified there since. They do not get updated with the package, and overwriting them loses the modifications. A comparison against the version inside the package finds that in minutes.
Templates reaching into internals. A Blade template addressing a framework class directly or reaching into an internal structure. No analysis flags it and it breaks at the jump.
On a platform with third-party extensions the same work sits differently again: Shopware 5 plugins in the migration to 6.
Pitfall 4: the project skeleton drifts
This is the pitfall that gets planned for least often and that is the largest single item in older projects.
Configuration files, the bootstrap file, exception handling, service registration: all of that was copied from a template when the project was created. The template has changed since, the project has not, and nothing about that difference announces itself.
It becomes visible through a comparison against a fresh project on the target version:
composer create-project laravel/laravel:^12.0 /tmp/fresh --no-scripts
for d in config bootstrap app/Providers app/Exceptions; do
echo "=== $d"
diff -rq "/tmp/fresh/$d" "$d" 2>/dev/null | grep -v "^Only in $d"
doneThe output is long the first time and sorts into two groups. What was deliberately customized stays and gets noted. What is merely old gets adopted. The distinction needs somebody who knows the project, and it is a day's work.
For projects several major versions behind, it is often faster to go the other way round: create a fresh project on the target version and pull your own code into it, rather than raising the old skeleton step by step. That is explicitly not a rewrite, because the application stays the same; it is a change of skeleton, and it is verifiable in a day.
A tool takes over the mechanical part, provided you scope it narrowly: Rector in a legacy project.
The order that has proved itself
From the four pitfalls follows a sequence that keeps fault-finding short.
- Create the golden master for the outputs the business sees. Before everything else, because it is the yardstick for every later step.
- Do the front-end chain separately, in its own release, on the old framework version.
- Settle the packages, abandoned ones first. After that the scope is known.
- One major version, not two. Here too: a project running in production on the next version after three weeks has locked in value.
- Align the skeleton, with the comparison above, as a commit of its own.
- Run the golden master again. Every difference is a finding, and every finding gets a decision: intended or bug.
The second item saves the most time and is the one most often skipped, because it feels like a detour.
About the PHP version underneath: it has its own rhythm and its own breaking changes, and it does not belong in the same release. What the PHP jump really involves is covered in its own article. How I set up a Laravel upgrade is on its own page.
This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.

