Okay, confession time. I migrated my big SaaS project to Laravel 12 the weekend it dropped in February 2025, and I was sure half the new defaults were going to bite me in production. Some of them did. Most didn’t. Fifteen months later, here’s what’s still in my codebase, and what I wish someone had warned me about before I ran composer update.
This isn’t a “what’s new” press release. The official release notes cover that fine. This is me opening up the project I’ve been shipping for over a year and telling you what’s still in the codebase versus what I tore back out a month later.
The Upgrade Itself Was Embarrassingly Boring
I want to start here because it sets the tone. Going from Laravel 11 to 12 took me about forty minutes, including coffee. No deprecations that mattered for my apps. No schema migrations. One tag bump in composer.json and I was done.
If you’re still on Laravel 10 or earlier, your upgrade is going to feel different. The big bloat-removal in Laravel 11 (no more app/Http/Kernel.php, no separate middleware file, leaner bootstrap) is where most of the actual work lives. By the time you’re on 11, jumping to 12 is mostly composer bumps. I wrote up a longer take on the slim skeleton when I migrated my Volt project, and it still holds.
The lesson I keep relearning: when an upgrade ships with no fanfare, that’s usually a good sign. Laravel does loud upgrades when the architecture moves. Quiet ones mean the team thinks the foundation is settled.
Inertia 2 Starter Kits: I Was Ready to Hate Them
The headline feature of Laravel 12, for me at least, was the rebuilt starter kits. They ship with Inertia 2, and they let you pick React, Vue, or Livewire as your frontend pairing. I was annoyed at first because I had my own scaffold and didn’t want to learn somebody else’s folder structure.
I caved on a small client project, and the deferred-prop pattern is what won me over. Here’s what I used to do in Laravel 11 with Inertia 1:
// Laravel 11 / Inertia 1
public function index(Request $request)
{
return Inertia::render('Dashboard', [
'user' => $request->user(),
'projects' => Project::with('team')->latest()->get(),
'invoices' => Invoice::query()
->where('user_id', $request->user()->id)
->latest()
->limit(50)
->get(),
'usage' => $this->expensiveUsageCalculation($request->user()),
]);
}
That expensiveUsageCalculation call blocked the whole page render. I’d watch the first contentful paint slip by 400ms because I was waiting on a Redis lookup nobody scrolled to.
With Inertia 2 in Laravel 12:
// Laravel 12 / Inertia 2
public function index(Request $request)
{
return Inertia::render('Dashboard', [
'user' => $request->user(),
'projects' => Project::with('team')->latest()->get(),
'invoices' => Inertia::defer(fn () => Invoice::query()
->where('user_id', $request->user()->id)
->latest()
->limit(50)
->get()
),
'usage' => Inertia::defer(
fn () => $this->expensiveUsageCalculation($request->user())
),
]);
}
The page paints with user and projects, and Inertia fires a follow-up request for the deferred props. On the React side I get a Deferred component that handles the loading state. My dashboard’s LCP dropped from 1.2s to about 480ms with that one change.
I keep this pattern. It’s the single most useful Inertia 2 thing for me.
WorkOS Auth: The Option I Keep on the Shelf
The starter kits also added a WorkOS option as an SSO provider. I read about this in the Laravel 12 launch notes and got excited, then went and turned it off.
Here’s the thing. WorkOS is good. I’m not knocking it. But for solo-dev SaaS work, plain Sanctum plus a Google OAuth button covers 95% of what I need. WorkOS becomes interesting the moment a customer asks “do you support SAML?” and you don’t want to write that integration yourself.
So I left the WorkOS scaffold in my two B2B projects and ripped it out of the three consumer-facing ones. Knowing it’s there as a one-flag-flip if a paying customer asks for SCIM is worth keeping in the starter kit, even if you don’t enable it on day one.
If you’re the only developer on a side project and nobody’s paying you to be SOC 2 compliant, you can skip WorkOS without guilt. The kit makes that easy now: it’s an optional install step, not a default.
The Streamlined Skeleton Bites Newcomers
Here’s the thing nobody warned me about. If you onboard a junior to a Laravel 12 codebase and they last touched Laravel 9, they will be confused for a week. There is no Kernel file. Middleware aliasing now lives in bootstrap/app.php as method calls on the application instance.
For middleware that used to look like this in Laravel 9-10:
// app/Http/Kernel.php (Laravel 9-10)
protected $middlewareAliases = [
'team.admin' => \App\Http\Middleware\EnsureTeamAdmin::class,
'subscribed' => \App\Http\Middleware\EnsureSubscribed::class,
];
Now lives here:
// bootstrap/app.php (Laravel 11-12)
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'team.admin' => \App\Http\Middleware\EnsureTeamAdmin::class,
'subscribed' => \App\Http\Middleware\EnsureSubscribed::class,
]);
})
->create();
It’s not worse, it’s just different, and the docs make it look more obvious than it actually is when you’re hunting through someone else’s repo. I now keep a README.md section in any new project that says “middleware aliases live in bootstrap/app.php” because I’ve answered that question on Slack three times in the last quarter.
If you’re hiring a contractor for a short project, factor a half day for them to get re-oriented if they’ve been off the framework for a year.
What I Dropped After Six Months
A short list of Laravel 12 defaults I removed from my projects.
The default Vite plugin pin got bumped. I’m running an older plugin for legacy reasons, so I pinned my plugin version in package.json and ignored the new default. Not a big deal, but if your build mysteriously slowed after the upgrade, check Vite first.
The WorkOS scaffold, as I said above, on consumer apps.
The new Pennant feature-flag defaults. I love Laravel Pennant, but I run it through a Postgres driver with a custom resolver, and the default kit configuration was eager-loading flags I didn’t need on every request. I pared it back to lazy resolution and saved about 30ms per request. If you’re using Pennant, profile it before trusting the defaults.
I kept everything else.
Performance: The Honest Numbers
I want to give you real numbers, not vibes. On my main SaaS app (PHP 8.3, Postgres 16, Redis 7, running on a 4-vCPU box):
Average response time on the dashboard route before Laravel 12 plus Inertia 2 deferred props: 312ms.
After: 187ms.
That’s not Laravel making PHP faster. PHP 8.3 to 8.3 is the same engine. That’s me using the deferred-prop pattern to take the slow stuff off the critical path. The framework gave me a clean way to do it. Before, I was hacking around the problem with JavaScript-side fetch calls and a janky loading skeleton I’d cobbled together.
For my background queues, no change. Laravel 12 didn’t touch the queue internals in a way I noticed. If anything, the Horizon dashboard feels a touch snappier, but I can’t prove it isn’t the React 19 frontend rewrite I shipped the same week.
What I’d Do Differently If I Re-upgraded Today
I’d run my own Inertia migration first, then bump Laravel. I did them at the same time and spent two days untangling which thing was breaking what. Some of those bugs were me, not Laravel or Inertia.
I’d also start using Inertia::defer in greenfield code from day one. The temptation is to put everything in the initial payload “for simplicity” and only optimize later. That’s how 1.2s dashboards happen. Defer anything that isn’t above the fold from the start.
If you’re picking up Laravel 12 today, the upgrade itself is not the project. The project is what you do with the new tools afterward.
One Concrete Thing to Try This Week
Pick one Inertia page in your app. The slowest dashboard, the admin index, whichever route makes you wince when you reload it. Wrap any prop that does a database query the user doesn’t see before they scroll in Inertia::defer. Reload the page in DevTools with the network tab open and watch the initial response shrink.
That’s it. You’ll know in about ten minutes whether deferred props help your app. If they don’t, you’ve lost ten minutes. If they do, you’ve found the pattern I use on every Laravel 12 project I ship now. I cover more of how I structure these pages in my portfolio work, if you want to see it in context.