Laravel 12 stopped getting bug fixes on August 13, 2026. Security fixes run until February 24, 2027, and then it’s done (Laravel support policy). Laravel 13 shipped on March 17, 2026. Its official upgrade guide estimates ten minutes.
For the framework code, that estimate is close. The risk sits around it. Laravel 13 needs PHP 8.3. PHPUnit 12 drops annotations. The CSRF middleware has a new name that can quietly undo your route exclusions. And some config defaults change only if your config files let them.
This guide covers those, in the order I’d do them.
If you’re still on Laravel 10 or 11, start with our Laravel 10 to 12 upgrade guide. Finish that move first, then come back here. Jumping straight to 13 stacks three majors into one pull request. When something breaks, you won’t know which version caused it.
Should You Upgrade to Laravel 13 Now?
The case for waiting is real. Laravel 12 still gets security patches for a few more months. Nothing in 13 forces your hand today. If your team is in the middle of a big release, I get it.
I’d still do it now. Bug fixes for 12 have already ended, so any framework bug you hit from here stays a bug. This is also one of the smallest major upgrades Laravel has shipped. Packages will start dropping 12 over the next year, and the upgrade only grows while you wait.
| Version | PHP | Released | Bug fixes until | Security fixes until |
| 12 | 8.2 to 8.5 | February 24, 2025 | August 13, 2026 | February 24, 2027 |
| 13 | 8.3 to 8.5 | March 17, 2026 | Q3 2027 | March 17, 2028 |
Source: Laravel 13 release notes, support policy.
Plan to be on 13 well before February 2027. A security deadline is a bad week to discover a package that won’t install.
Move to PHP 8.3 First, While You’re Still on Laravel 12
Laravel 13 requires PHP 8.3 or newer. Laravel 12 runs on 8.2 to 8.5. That overlap is the useful part. You can upgrade PHP on your current Laravel version, deploy it, and watch it on its own.
Do it that way. If you change PHP and Laravel in one release, every error has two possible causes.
When PHP is too old, Composer stops before it installs anything. The line to look for ends with your php version (8.2.x) does not satisfy that requirement.
Check every place that runs your code. Web servers are the obvious one. Queue worker hosts, the scheduler server, CI images, and Docker base images all carry their own PHP version. The one teams forget is usually CI, or a worker server nobody has logged into for a year.
Update Composer: Framework, Tinker, PHPUnit 12, and Pest 4
The dependency change itself is small. The upgrade guide lists five packages. Every default app ships with Tinker, so don’t skip it, or Composer will report a conflict:
"require": {
"php": "^8.3",
"laravel/framework": "^13.0",
"laravel/tinker": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^12.0"
}
If you use Pest, require pestphp/pest ^4.0 as well. If you use Laravel Boost, move laravel/boost to ^2.0.
Before you run composer update, find out which of your packages block Laravel 13. Run composer why-not laravel/framework 13.0. Our 10 to 12 guide walks through reading that output, so I won’t repeat it here. One rule still stands: don’t reach for --ignore-platform-reqs. It hides the problem until production finds it.
PHPUnit 12 can make tests disappear
This is the change that worries me most, because nothing fails.
PHPUnit 12 removed annotation support completely. In the PHPUnit team’s words, “Support for using annotations has now been removed and you need to migrate your tests from annotations to attributes” (PHPUnit 12 announcement).
So a method marked /** @test */ that isn’t named test... is no longer a test. PHPUnit doesn’t run it, and it doesn’t warn you about that method. Your suite goes green with fewer tests in it.
Find them before you upgrade:
grep -rnE "@(test|dataProvider|depends|group)\b" tests/
Then write down the test count from your last run on Laravel 12. Compare it with the first run on 13. If the number dropped, an annotation is still hiding somewhere.
PHPUnit 12 also requires PHP 8.3. Your CI image has to move with it.
CSRF Protection Has a New Name, and Your Exclusions May Not Follow It
The upgrade guide marks this one high impact, and it’s right. The CSRF middleware VerifyCsrfToken is now called PreventRequestForgery. It also works differently.
The new middleware checks the browser’s Sec-Fetch-Site header first. If the request came from the same origin, it passes without a token check. If not, Laravel falls back to the normal CSRF token. Browsers only send that header over HTTPS, so on plain HTTP you get token checks only (Laravel 13 CSRF docs). As before, this runs on routes in the web middleware group.
Route exclusions that quietly stop working
Here’s the part the rename hides. On the default bootstrap/app.php setup, the web group now registers PreventRequestForgery. The old VerifyCsrfToken class still exists, but only as a deprecated child class of the new one.
Laravel’s route exclusion removes a middleware, or any class that extends it. It doesn’t remove a parent class. So this exclusion now does nothing:
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
// Laravel 13: excludes nothing
Route::post('/webhooks/payments', PaymentWebhookController::class)
->withoutMiddleware([VerifyCsrfToken::class]);
// Laravel 13: does what you meant
Route::post('/webhooks/payments', PaymentWebhookController::class)
->withoutMiddleware([PreventRequestForgery::class]);
The cost is easy to picture. Your payment provider sends a webhook and gets a 419. It retries a few times, then gives up. Your code never sees the request, so nothing shows up in your exception tracker.
Search for it now:
grep -rn "VerifyCsrfToken\|ValidateCsrfToken" app/ routes/ bootstrap/ tests/
My preference is to move webhooks out of the web group completely. The Laravel docs suggest the same. A webhook doesn’t need sessions, cookies, or a CSRF token.
If your app still uses the old app/Http/Kernel.php with its own App\Http\Middleware\VerifyCsrfToken, this trap doesn’t apply. Your class is still the one in the group. Rename it anyway, because the parent is deprecated.
The validateCsrfTokens(except: [...]) call in bootstrap/app.php keeps working. It’s now a deprecated wrapper around preventRequestForgery(except: [...]). Switch it while you’re in the file.
Subdomains and the origin-only option
If app.example.com posts forms to example.com, the browser marks those requests as same-site, not same-origin. Allow them on purpose:
->withMiddleware(function (Middleware $middleware): void {
$middleware->preventRequestForgery(allowSameSite: true);
})
There’s also an originOnly: true mode that drops the token fallback and returns 403 instead of 419. I’d leave it off for now. Any internal tool served without HTTPS gets no Sec-Fetch-Site header, so every form post would fail.
Config Defaults That Change Only If You Let Them
Three changes in the guide are about default values: the cache prefix, the Redis prefix, and the session cookie name. A fourth adds a new cache security setting. Whether they hit you depends on your config/ folder, and most upgrade posts skip that detail.
Laravel merges its own config defaults under yours. If your config/cache.php sets a key, your value wins. If the file or the key is missing, the framework default applies. Apps that trimmed their config files, or never published some of them, get the new defaults.
Cache prefix, Redis prefix, and session cookie
| Setting | Laravel 12 default | Laravel 13 framework fallback |
| Cache prefix | Str::slug(APP_NAME, ‘_’).’_cache_’ | Str::slug(APP_NAME).’-cache-‘ |
| Redis prefix | Str::slug(APP_NAME, ‘_’).’_database_’ | Str::slug(APP_NAME).’-database-‘ |
| Session cookie | Str::slug(APP_NAME, ‘_’).’_session’ | Str::snake(APP_NAME).’_session’ |
Each one has its own consequence on deploy day.
A new cache prefix means every key misses. Your cache starts empty, and the database takes the full load until it warms up.
A new Redis prefix is worse if you queue jobs on Redis. Jobs pushed before the deploy sit under the old prefix. The new workers look under the new one and never see them.
The session cookie is the one people miss. The table shows the framework’s fallback, which applies when your config doesn’t set the key. For MyApp, it turns myapp_session into my_app_session. The new Laravel 13 skeleton’s config/session.php uses Str::slug(APP_NAME).'-session' instead, which gives myapp-session. Either way the cookie name changes, and every logged-in user is logged out.
The fix takes five minutes, and you do it before the upgrade. On Laravel 12, read your current values:
php artisan tinker --execute="dump(config('cache.prefix'), config('database.redis.options.prefix'), config('session.cookie'));"
Put those exact values in .env as CACHE_PREFIX, REDIS_PREFIX, and SESSION_COOKIE. Deploy that on 12. Now the upgrade can’t move them.
There’s a second logout hiding in the new skeleton. Its config/session.php sets 'serialization' => 'json', where it used to be php. The upgrade guide says syncing that value invalidates every active session. Keep it at php during the upgrade. Switch to json later, as its own change, once you know nothing stores PHP objects in the session.
Cache serialization: serializable_classes
New Laravel 13 apps ship with 'serializable_classes' => false in config/cache.php. That blocks the cache from unserializing PHP objects. It’s a good security default, because unserializing untrusted data is a classic attack path.
Here’s the detail that matters for upgrades. The framework’s own fallback for this key is “no restriction”. The 13.x source shows it: if your config/cache.php doesn’t have the key, cached objects still load like they did on 12.
It bites when someone copies the new skeleton’s config/cache.php into the app. Then any cached object comes back as __PHP_Incomplete_Class, and you see errors like Return value must be of type ..., __PHP_Incomplete_Class returned.
I’d adopt the new setting, but as its own change after the upgrade. List the classes you cache on purpose:
'serializable_classes' => [
App\Data\CachedDashboardStats::class,
],
Better still, cache arrays instead of objects where you can.
Smaller Changes That Can Still Cost You Data
Most of the low-impact items won’t touch a typical app. A few can, and two of them touch your data.
DELETE with JOIN and LIMIT. On Laravel 12, ORDER BY and LIMIT were left out of DELETE ... JOIN queries. So a delete you meant to cap at 1,000 rows could delete every matching row. Laravel 13 includes them. MySQL, and MariaDB before 11.8.1, don’t allow this syntax, so that query now throws a QueryException. I’d much rather have the exception. If you see one after the upgrade, check what that query was deleting on 12.
upsert() with an empty uniqueBy. The guide rates this one medium impact. MySQL and MariaDB ignore uniqueBy and match on the table’s primary and unique indexes, so some code passes an empty value there. On Laravel 13, that upsert() throws an InvalidArgumentException instead of running. Search your code for upsert( and make sure every call names its unique columns.
The rest are quick to check:
- Queued Eloquent collections now restore their eager-loaded relations when a job runs. Heavy jobs may run extra queries.
- Listeners on
JobAttemptedmust read$event->exceptioninstead of$event->exceptionOccurred.QueueBusy::$connectionis now$connectionName. These usually live in monitoring code. - The default password reset subject is now “Reset your password”. Tests that assert the old subject will fail.
- Custom polymorphic pivot classes now get plural table names. Set
$tableif yours were singular. - Creating a model instance inside that model’s
bootmethod now throws aLogicException.
The upgrade guide lists the full set. Read the low-impact section once, with your codebase open.
What to Use Once You’re on 13
Laravel 13 adds real features (release notes). You get queue routing with Queue::route(...) and attributes like #[Tries] on jobs. There’s Cache::touch() to extend a TTL, a first-party AI SDK, and JSON:API resources.
Leave all of them out of the upgrade pull request. The upgrade should change versions and fix what broke, nothing else. Then adopt features one at a time, where they replace code you already maintain.
Deploying the Upgrade
The deploy itself works like any other major upgrade. Our 10 to 12 guide covers the full checklist, and our Laravel 12 breaking changes reference covers what came before this release.
For 13, add three things. Ship the .env prefixes on Laravel 12 first, as a separate deploy. Restart your queue workers after the upgrade, because they keep the old code in memory. And for the first day, watch your access logs for 419 responses and your payment provider’s dashboard for failed webhooks.
The Short Version
Upgrade to Laravel 13 now, while it’s small. The framework part really is close to ten minutes. The real work is around it. PHP 8.3 has to reach every server. PHPUnit 12 quietly drops annotated tests. CSRF exclusions may point at the old class, and some defaults were never pinned.
One thing would change this advice. If a package you depend on has no Laravel 13 release yet, stay on 12. But move to PHP 8.3 anyway, and keep an eye on February 24, 2027.
Start today with three checks. Run php -v on every server and CI image. Run the annotation grep on your tests, and the VerifyCsrfToken grep on your routes. That’s ten minutes, and it tells you how big your upgrade really is.
If one of them turns up something you’d rather hand off, send us one real problem from your backlog. We’ll fix it free, with no sales call.
Frequently Asked Questions
How long does a Laravel 12 to 13 upgrade take?
The official upgrade guide estimates ten minutes for the framework changes. The real time depends on three things. Are you already on PHP 8.3? Do your packages support Laravel 13? And how many PHPUnit annotations do your tests still use?
What PHP version does Laravel 13 require?
Laravel 13 supports PHP 8.3 to 8.5 (support policy). Laravel 12 supports 8.2 to 8.5, so you can upgrade PHP first and deploy it before touching Laravel.
Can I upgrade from Laravel 10 or 11 straight to 13?
You can, but I wouldn’t. Upgrade one major version at a time and test after each step. Our Laravel 10 to 12 guide covers the first part of that path.
When does Laravel 12 support end?
Bug fixes for Laravel 12 ended on August 13, 2026. Security fixes end on February 24, 2027 (support policy).
Why does my webhook return 419 after upgrading to Laravel 13?
Most likely the route excludes VerifyCsrfToken, but the web group now runs PreventRequestForgery. Excluding the old class doesn’t remove the new one. Exclude PreventRequestForgery instead, or move webhook routes out of the web group.
Do I have to rename VerifyCsrfToken in Laravel 13?
Not right away. Both VerifyCsrfToken and ValidateCsrfToken still exist as deprecated classes that extend PreventRequestForgery (upgrade guide). Because they are child classes, excluding them from a route doesn’t remove the parent. Rename them during the upgrade, starting with route exclusions.
Tags
Ankur Patel
Software Engineer
Related Articles
Laravel Performance: Catch Slow Code Early
Table of contentsHide Find Risky Code in Two Minutes Set Up These Guardrails First 1.…
Laravel E-commerce: Build a New Store or Migrate
Table of contentsHide Key Principles in This Guide Custom Laravel or a Hosted Platform? Start…
Laravel 12 Architecture: Where Business Logic Belongs
Table of contentsHide 1. Check What Laravel Already Gives You 2. The Fat Controller, and…
Leave a Comment