Laravel 12, and a starter kit we did not use

Laravel 12 arrived in February with an upgrade guide shorter than most patch notes. A major version that changes almost nothing is either a mature framework or a stalled one, and the interesting part of this upgrade turned out to be the thing that was removed rather than anything that changed.

The symptom

$ composer require laravel/framework:^12.0 --with-all-dependencies
$ vendor/bin/phpunit
  Tests: 1,414 passed

$ git diff --stat
 composer.json  |  2 +-
 composer.lock  | 88 ++++++++--------

# elapsed: 55 minutes, of which 50 was CI.

One line changed in the application. That is the whole upgrade, and writing it down is worth doing because the record of an uneventful upgrade is the evidence for having kept up with the previous ones.

Why it happens

An annual release cadence means a major ships when the date arrives rather than when there is something large to ship. The alternative — waiting until there is — produces the multi-year majors that make upgrades into projects.

The fix

What actually changed

for an existing application:

  the starter kits moved out of the framework into
    separate repositories
  a handful of defaults for NEW installs
  a minimum PHP of 8.2, which we exceeded by two
  a small number of deprecated methods removed, none
    of which we used

for a new application:
  the starter kits are now a choice made at
  installation rather than a scaffold command, and
  there are three of them.

The one deprecation that mattered

// removed in 12.0
$request->merge(['x' => 1])->all();

// which we did not use. what we did use:
Rule::unique('users')->ignore($user);

// unchanged, and the deprecation notice in 11.x was
// for the string-based form we had already migrated
// away from in 2024 — which is why the upgrade was 55
// minutes rather than a day.

The cheapness of this upgrade is entirely a consequence of having acted on the previous version’s deprecation notices, which is the same lesson as the last three PHPUnit majors. A deprecation channel that logs somewhere somebody reads is the mechanism, and it is one configuration line that has now been set to null twice by accident.

Reading the starter kit as a reference

what a starter kit provides:
  registration, login, password reset, email
  verification, two-factor, a profile page, a settings
  page, and a frontend stack decision

what we have:
  all of the above, written between 2018 and 2021,
  with our own two-factor flow

what an hour of reading theirs found:
  our password reset did not invalidate other sessions.
  theirs does.

  one commit, eleven lines, and a genuine security
  improvement from reading somebody else's complete
  implementation of a thing we wrote piecemeal.

The session invalidation is the finding and it is the kind that only surfaces by comparison — nothing about our implementation looked wrong, and the absence of a step is invisible when you are reading your own code. A complete reference implementation is the cheapest audit available and it arrives free with every framework.

Why not adopt it

  the dependency        a package, its frontend stack,
                        and its opinions about routing
  the auth code we
    already have         four years old, tested,
                        integrated with our
                        authorisation model
  the migration          replacing working
                        authentication is the highest-
                        risk change available for the
                        lowest visible benefit

an eight-year-old application is not the audience for a
starter kit, and reading it is worth more than adopting
it.

Verifying it worked

$ php artisan about --only=environment
  Laravel Version  12.0.4
  PHP Version      8.4.3

$ ./bin/route-diff --before=routes-11.json
  routes: 188, identical
  middleware: identical for all 188

$ ./bin/response-times --sample=2000
  p50  38ms      # 38ms before
  p95 178ms      # 180ms before

$ vendor/bin/phpunit --filter Session
  ✓ a password reset invalidates other sessions
  # added, and failing before the fix

The route diff is the check worth keeping for any framework upgrade, because the failure mode is a middleware that quietly stops applying to one group. The new session test is the actual output of this upgrade and it has nothing to do with the version number.

What this costs

Nothing, this year. The cost of an annual cadence is that it produces a major every twelve months whether or not anything warrants one, and each of those is a version constraint to bump, a CI run and a paragraph of reading — which is cheap and is not free, and compounds across every dependency that has adopted the same rhythm.

It also means the meaningful signal is in the deprecation notices rather than in the version number, which inverts the usual instinct. A major that changes nothing and a minor that deprecates six methods are not distinguishable from the outside, and the second one is where the work is.