/

Laravel 12: the release where almost nothing broke

2,174 words, about 10 min read

The upgrade branch came to eleven files. Two were composer.json and composer.lock, six were date arithmetic buried in reports, and the rest were the tests that caught the date arithmetic. We went from Laravel 11.44 to 12.2.0 on a Wednesday afternoon in March, ran the suite, fixed nine failures that were all the same bug wearing different hats, and deployed the next morning. Hands-on time was under an hour, plus one working day sitting on staging because our release process says so and not because anything looked wrong.

I have been doing Laravel major upgrades on this app since version 9, and that has never happened before.

The upgrade guide is one page

Laravel 12 shipped on 24 February 2025. The upgrade guide puts the estimated upgrade time at five minutes, which I assumed was marketing until I read the whole page: two high impact items (bump dependencies, update the installer), one medium impact item (UUIDv7), and six low impact items. That is the entire document. Last year’s move from 10 to 11 was a different kind of job. The skeleton rewrite there was technically optional, we opted in anyway, and it cost us the better part of a week.

Our composer change was this:

-        "laravel/framework": "^11.31",
+        "laravel/framework": "^12.0",
-        "phpunit/phpunit": "^10.5",
+        "phpunit/phpunit": "^11.5",

The app is an internal ops and finance tool used daily by dispatch, support and finance. It has no public signup, no marketing site, and a frontend that is Blade plus a thin layer of Alpine. Three of us maintain it. That profile turns out to be exactly the profile Laravel 12 was designed not to bother.

What is actually in the release

The release notes are unusually direct about it. The framework team calls Laravel 12 a “maintenance release” whose purpose is to update upstream dependencies, and says most applications may upgrade without changing any application code. The stated reasoning is that quality-of-life work now ships continuously in minors instead of being saved up for the annual major.

The supported PHP range is 8.2 to 8.4, unchanged from Laravel 11. We are on PHP 8.3 across both applications, so that was a non-event. Bug fixes for Laravel 12 run until 13 August 2026 and security fixes until 24 February 2027, which is the number I actually care about when I am arguing for upgrade time in a planning meeting.

The breaking changes worth knowing, all from the upgrade guide:

  • Carbon 2 support is gone. Every Laravel 12 app is on Carbon 3.
  • HasUuids now generates UUIDv7 instead of ordered UUIDv4. If you need the old behaviour, swap the trait for HasVersion4Uuids. The old HasVersion7Uuids trait was removed.
  • The image validation rule no longer accepts SVG files, because of the XSS risk.
  • Concurrency::run() with an associative array now returns results keyed by those keys instead of by position.
  • The container respects default values on constructor properties, so public ?Carbon $date = null now resolves to null rather than a Carbon instance.
  • Schema::getTables(), getViews() and getTypes() cover all schemas by default, and Schema::getTableListing() returns schema-qualified names.
  • DatabaseTokenRepository‘s constructor takes $expires in seconds now, not minutes.
  • $request->mergeIfMissing() understands dot notation, so 'user.last_name' => 'Otwell' creates a nested array instead of a literal top-level key.

Two of those hit us. One of them cost real money.

Carbon 3 is the whole upgrade

Carbon’s own migration guide is blunt: the most impactful change in version 3 is that diffIn* methods used to return a positive integer and now return a float, signed to indicate direction. Their example is worth reading twice.

$after = Carbon::now()->addSeconds(2);
$before = Carbon::now();

var_dump($after->diffInSeconds($before));
// Carbon 2: int(1)
// Carbon 3: float(-1.999627)

We had this in a finance report that buckets unbilled shipments by age:

// app/Reports/UnbilledAging.php, the Carbon 2 version
$age = now()->diffInDays($shipment->booked_at);

if ($age >= 30) {
    $bucket = '30+';
}

Read left to right that says “days from now back to when it was booked”, and under Carbon 2 it returned a positive integer. Under Carbon 3 it returns something like float(-34.7118), so $age >= 30 was never true and every row landed in the youngest bucket. Nothing threw. The report rendered. The 30-day reminder queue simply sent nothing for two days, until someone in finance asked why the aging column looked wrong on a shipment she knew was from February.

That is the failure mode I dislike most: a signature change that produces a plausible number instead of an exception. The fix is small, and Carbon documents it:

$age = (int) $shipment->booked_at->diffInDays(now(), true);

The second argument is $absolute, and casting to int truncates, which together reproduce the Carbon 2 result. We ended up grepping for diffIn across the app: 23 call sites, of which 6 were compared against a threshold or passed into floor(), and those 6 were the ones that mattered. The other Carbon 3 changes we ran into were loud and therefore harmless. Calling isSameDay() with no argument is no longer allowed, because the implicit “now” default is gone, so you either pass a date or use isCurrentDay(). Comparison methods are properly typed now, so the two places where we were passing a possibly-null value into gte() threw a TypeError in CI instead of quietly returning true. Also worth knowing before you migrate: createFromTimestamp() now defaults to UTC rather than the process default timezone, and Carbon::minValue() and maxValue() are gone in favour of CarbonImmutable::startOfTime() and endOfTime().

If you want one takeaway from this post: the Laravel 12 upgrade is a Carbon 3 upgrade wearing a Laravel hat. Budget your time there.

The starter kits, seen from an app that already has a frontend

The headline feature of Laravel 12 is a set of new application starter kits. Per the docs there are three: React, Vue and Livewire. The React and Vue kits are built on Inertia 2, TypeScript, Tailwind 4 and shadcn (shadcn/ui for React, shadcn-vue for Vue). The Livewire kit uses Livewire 3, Tailwind and the Flux UI component library, with Volt for the single-file component style. Each kit also comes in a WorkOS AuthKit variant that hands off login to WorkOS and gives you social login, passkeys, magic links and SSO. That variant requires a WorkOS account, and the docs state WorkOS offers free authentication up to one million monthly active users. Free up to a million is generous. It is still a third party in your login path, and the honest way to describe the default kits is that they use Laravel’s own authentication and the WorkOS ones do not.

Alongside this, Breeze and Jetstream will no longer receive additional updates. That sentence is in the release notes, and it is the only part of the starter kit story that matters to an existing codebase. Nothing was deleted from Packagist and nothing stopped working, but if you are running Jetstream for its teams and two-factor features, you now own that code in a way you did not last year.

For us, none of this applies. Our login screen was scaffolded from Breeze in 2023 and has been hand-edited ever since, so it was already our code. The starter kits are for new applications, and the FAQ says as much: you take full ownership of the code and there is no need to update the kit itself. That is a reasonable model. It also means “new starter kits” is not a feature you can adopt. You can only start with it.

I did run laravel new with the Vue kit on my laptop to see what a 2025 greenfield Laravel app looks like. It is nice. Inertia 2 plus typed page props is a better developer experience than what we have. It is also three or four hundred files of frontend I would have to take responsibility for, against a Blade app that dispatch staff already know how to use, so it is not happening this year.

What broke after the upgrade rather than during it

Two things, and only one was Laravel’s fault.

Ours: a support ticket a week after deploy, from a merchant onboarding form where ops upload a partner logo. The message was “The logo field must be an image.” for a file that was very clearly an image. It was an SVG, and the image rule stopped accepting SVGs in 12.0. You can opt back in with image:allow_svg, or File::image(allowSvg: true) with the fluent builder. We did not. We serve those logos on printed manifests and in emails, the XSS reasoning in the docs is correct, and the real fix was to stop pretending SVG was acceptable input:

$request->validate([
    'logo' => ['required', 'file', 'mimes:png,jpg,webp', 'max:512'],
]);

Theirs: we track minors weekly, and the weekend of 29 March we picked up 12.4.0, which carried a URL generation regression for routes with optional parameters. Staging caught it, the framework shipped 12.4.1 the next day with the fix, and we moved on. Then in May, 12.12.0 made the schema Blueprint resolver static and 12.14.0 reverted it about two weeks later. Neither cost us anything, but both are the shape of the new risk: when the major is quiet, the interesting changes arrive in weekly minors, which get far less scrutiny from the rest of us than an annual release does. We now pin exact versions in composer.lock (we always did) and read the changelog before bumping (we did not always).

The other side of that bargain is that the good stuff also arrives mid-year. Since February we have picked up the memoized cache driver in 12.9.0, automatic relation loading in 12.8.0, and Rule::anyOf() for validating a field against one of several rule sets. None of those needed a major version. That is the argument for the new release philosophy, made in the changelog rather than in a keynote.

It also explains where the effort went this cycle. Laravel 12 was not the main event on 24 February: the same day brought Laravel Cloud out of the waitlist, along with a rebuilt laravel.com and the starter kits. The framework was the quiet item on a launch day mostly about hosting and scaffolding. I have no complaint about that. Both apps run on our own EC2 boxes and Cloud solves a problem we do not have, but it does tell you which part of the ecosystem got the attention, and it makes the “nothing broke” release read less like modesty and more like scheduling.

What boring buys us

Planning. Last year’s Symfony 6.4 to 7.0 move on the customer platform was a multi-sprint project with a deprecation backlog, a risk register and a rollback plan. This year’s Laravel 11 to 12 move on the ops app was a ticket. I can put a one-line item in a sprint, say it is an afternoon, and be right. When you maintain a secondary application with three people, the difference between “an afternoon” and “a sprint” decides whether the upgrade happens at all, and applications that skip upgrades are how you end up on an unsupported framework in 2028.

The part I have not resolved: Laravel now expects you to live on the latest minor to get anything, which pushes the maintenance burden from one predictable annual event into a weekly habit. That suits a team with good tests. I am less sure it suits the freelance projects I touch twice a year, where the client’s app is thirty minors behind and the changelog between here and there is not readable in one sitting.

Next on my list is the diffIn audit for those clients, ahead of time, before someone’s aging report quietly goes flat.

Sources

  • Laravel 12 release notes: the “maintenance release” framing, minimal breaking changes, the starter kit lineup, the WorkOS variant, the Breeze and Jetstream freeze, and the support policy table with PHP 8.2 to 8.4 and the August 2026 and February 2027 dates.
  • Laravel 12 upgrade guide: the five minute estimate, the dependency bumps, Carbon 3, UUIDv7 and HasVersion4Uuids, the container default value change, multi-schema inspecting, mergeIfMissing dot notation, the DatabaseTokenRepository seconds change, and the SVG exclusion.
  • Carbon migration guide: diffIn* returning signed floats with the float(-1.999627) example, the absolute plus cast workaround, strong typing in comparisons, isSame* requiring an argument, UTC default for createFromTimestamp, and the removal of minValue and maxValue.
  • laravel/framework CHANGELOG-12.x: the Carbon 2 removal and UUIDv7 switch in v12.0.0, the URL generation regression fixed in v12.4.1, the Blueprint resolver change in v12.12.0 and its revert in v12.14.0, the memoized cache driver in v12.9.0, automatic relation loading and Rule::anyOf() in v12.8.0.
  • Starter kits documentation: Inertia 2, React 19, Tailwind 4, shadcn/ui and shadcn-vue, Livewire 3 with Flux UI and Volt, the WorkOS account requirement and the one million monthly active user free tier, and the “no need to update the starter kit itself” answer.
  • laravel/laravel 12.x composer.json: the skeleton’s php: ^8.2 and laravel/framework: ^12.0 constraints.
  • Validation documentation: the image:allow_svg directive and File::image(allowSvg: true), with the XSS reasoning.
  • Laravel News, Laravel 12 is now released: release date of 24 February 2025 and the PHP 8.2 minimum.
  • Laravel News, Laravel Cloud launch date: Cloud, the new website and the new starter kits all landing on the same day as the framework.