Laravel 12 to 13 upgrade guide: breaking changes, config gotchas and a safe rollout
A Laravel 12 to 13 upgrade guide: PHP 8.3 requirement, the breaking changes that bite, cache and session config traps, and a safe rollout plan.
A Laravel 12 to 13 upgrade is mostly a dependency bump: raise PHP to 8.3 or newer, require laravel/framework:^13.0, update Tinker, PHPUnit or Pest, and fix the handful of breaking changes that touch your code. The official guide estimates about ten minutes, and for a small app that is fair. On a production app the time goes into the parts the estimate leaves out: package compatibility, config files you copy from the new skeleton, cache and session keys, and a rollout that does not log every user out.
This Laravel 13 upgrade guide covers all of it, checked against the official upgrade guide (opens in a new tab) and release notes (opens in a new tab) as of October 2026.
Quick answer: how to upgrade Laravel 12 to 13 in seven steps
- Move the server and CI to PHP 8.3, 8.4 or 8.5 first, while still on Laravel 12.
- Run
composer why-not laravel/framework "^13.0"to find packages that block the upgrade. - In
composer.json, setlaravel/frameworkto^13.0,laravel/tinkerto^3.0,phpunit/phpunitto^12.0orpestphp/pestto^4.0, andlaravel/boostto^2.0if you use it. - Run
composer update, then your full test suite. - Replace references to
VerifyCsrfTokenwithPreventRequestForgery. - Pin
CACHE_PREFIX,REDIS_PREFIXandSESSION_COOKIEin.envso keys do not change under you. - Deploy to staging, then production, with a tested rollback.
Should you upgrade to Laravel 13 now?
Yes, for most apps, and the support dates make the case. Laravel 13 was released on March 17, 2026. Laravel 12 stopped getting bug fixes on August 13, 2026 and gets security fixes only until February 24, 2027. Laravel 11 reached the end of security support on March 12, 2026, and Laravel 10 a year before that.
| Version | PHP | Bug fixes until | Security fixes until |
|---|---|---|---|
| Laravel 10 | 8.1 to 8.3 | August 6, 2024 | February 4, 2025 |
| Laravel 11 | 8.2 to 8.4 | September 3, 2025 | March 12, 2026 |
| Laravel 12 | 8.2 to 8.5 | August 13, 2026 | February 24, 2027 |
| Laravel 13 | 8.3 to 8.5 | Q3 2027 | March 17, 2028 |
The PHP side matters just as much. PHP 8.2 gets its last security fix on December 31, 2026 (per php.net (opens in a new tab)). If your Laravel 12 app still runs on 8.2, you have to move PHP soon anyway, and once you are on 8.3 the framework bump is the smaller job.
The release notes describe Laravel 13 as a release focused on minimal breaking changes. Laravel 13 new features include the first-party Laravel AI SDK, JSON:API resources, queue routing with Queue::route(), more PHP attributes (#[Middleware], #[Authorize], #[Tries], #[Timeout]), Cache::touch() to extend a TTL, and vector similarity queries for PostgreSQL with pgvector. None of these are required to upgrade. You can adopt them later.
Laravel 13 PHP version requirement
Laravel 13 requires PHP 8.3 at minimum and supports 8.3, 8.4 and 8.5. Do the PHP move as its own deploy before the framework upgrade. Laravel 12 already runs on 8.3 to 8.5, so you can change one thing at a time and know which change broke what.
php -v
composer check-platform-reqs
check-platform-reqs confirms that the PHP version and extensions on the server satisfy your lock file. If you are coming from an older PHP, my PHP 7.4 to 8 upgrade checklist covers the deprecations that tend to surface on 8.3 and 8.4, such as implicitly nullable parameter types.
Pre-flight checks before you touch composer.json
Most failed upgrades fail on a package, not on the framework. Admin panels are a common example: confirm your Filament or Nova version supports Laravel 13 first (Filament vs Nova covers both). Ask Composer what blocks you before you edit anything:
git checkout -b upgrade/laravel-13
composer why-not laravel/framework "^13.0"
composer outdated --direct
why-not lists every installed package whose constraints reject Laravel 13. For each one, check Packagist or the changelog for a release that allows illuminate/* ^13.0. When I checked Packagist in October 2026, current releases of Horizon, Sanctum 4, Telescope, Pulse, Octane, Cashier, Livewire 3 and 4, Filament 3 to 5, Inertia's Laravel adapter and spatie/laravel-permission all allowed Laravel 13. Abandoned or small packages are the usual blockers. For those you choose between a fork, a replacement, or removing the feature.
Then check your safety net:
- Tests. Run the suite on Laravel 12 first and fix anything already red. If coverage is thin, write a few feature tests for login, checkout or whatever earns money before you start.
- Error tracking. Make sure production exceptions reach you (Sentry, Flare, Nightwatch or plain log alerts), so problems after deploy are visible within minutes.
- A database backup and a note of the current commit hash for rollback.
Step-by-step Laravel 12 to 13 upgrade
Update the dependencies the official guide lists as high impact. A typical require and require-dev block after the change:
{
"require": {
"php": "^8.3",
"laravel/framework": "^13.0",
"laravel/tinker": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^12.0",
"laravel/boost": "^2.0"
}
}
If you use Pest, require pestphp/pest at ^4.0 instead of pinning PHPUnit directly. Update your other first-party and third-party packages to the versions you found in the pre-flight step, then:
composer update
php artisan optimize:clear
php artisan test
If you installed the Laravel installer globally, update it too with composer global update laravel/installer. That only matters for creating new projects.
Two automation options are worth knowing. Laravel Shift (opens in a new tab) is a paid, community-run service that opens a pull request with the upgrade changes. Laravel Boost 2 adds a /upgrade-laravel-v13 command for AI coding assistants. Both save time, and both still need a human to review the diff and run the tests.
Laravel 13 breaking changes that actually bite
The official guide lists more than twenty changes, most rated low or very low impact. These are the ones I would search a codebase for first, with the fix for each.
CSRF middleware renamed to PreventRequestForgery (high impact)
VerifyCsrfToken is now PreventRequestForgery. The old names remain as deprecated aliases, so nothing breaks on day one, but update direct references, mostly in tests and route files:
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
// Laravel 12
// ->withoutMiddleware([VerifyCsrfToken::class]);
// Laravel 13
->withoutMiddleware([PreventRequestForgery::class]);
The middleware now checks the browser's Sec-Fetch-Site header first and falls back to token validation. The Laravel 13 docs list excluded URIs on the new method in bootstrap/app.php (Laravel 12 used validateCsrfTokens(except: ...)):
->withMiddleware(function (Middleware $middleware): void {
$middleware->preventRequestForgery(except: [
'webhooks/*',
]);
})
Test every route that receives a cross-site POST, such as payment gateway callbacks and webhooks, on staging over HTTPS.
upsert() needs a non-empty uniqueBy (medium impact)
On MySQL and MariaDB, the uniqueBy argument used to be ignored, so some code passed an empty array. Laravel 13 throws an InvalidArgumentException instead:
// Throws in Laravel 13
Price::upsert($rows, [], ['amount']);
// Pass the real unique columns
Price::upsert($rows, ['product_id', 'currency'], ['amount']);
Search for upsert( across the codebase. Bulk import jobs are where this hides.
Joined DELETE with ORDER BY or LIMIT (low impact, high stakes)
Laravel now compiles ORDER BY and LIMIT into DELETE ... JOIN queries for MySQL. Before, those clauses could be silently dropped, which means the delete removed more rows than the code suggested. MySQL (and MariaDB before 11.8.1) does not support that syntax, so these queries now throw a QueryException. Treat any hit as a bug you just found, and rewrite it as a select of IDs followed by a whereIn delete.
Queue events, pagination views and smaller renames
JobAttemptednow exposes$event->exception(the exception ornull) instead of the boolean$event->exceptionOccurred.QueueBusyrenamed$connectionto$connectionName.- Bootstrap 3 pagination views are now
pagination::bootstrap-3andpagination::simple-bootstrap-3, replacingpagination::defaultandpagination::simple-default. - Polymorphic pivot models with custom pivot classes now get pluralized inferred table names. Set
$tableexplicitly if you relied on the singular name. - Routes with an explicit domain now match before routes without one.
Container::call()now returnsnullfor a nullable class parameter with anulldefault when no binding exists.- Creating a model instance inside a model's own
bootmethod now throws aLogicException. - Manager
extendclosures are now bound to the manager, so$thisinside them changed. - The default password reset subject is now "Reset your password". Update tests that assert on the old text.
array_first() and array_last() helper conflicts
Laravel 13 depends on symfony/polyfill-php85, which defines global array_first() and array_last() on PHP below 8.5. Older helper packages such as laravel/helpers defined functions with the same names but a different signature (they accepted a callback). If your code calls array_first($items, fn ...), switch to Illuminate\Support\Arr:
use Illuminate\Support\Arr;
$firstPaid = Arr::first($invoices, fn (array $invoice) => $invoice['status'] === 'paid');
Config defaults that changed under you
This is where the "ten minutes" estimate misleads, because none of these throw an error. They change behavior quietly.
Cache prefixes and session cookie names
The framework's fallback prefixes changed from underscores to hyphens: myapp_cache_ becomes myapp-cache-, the Redis prefix myapp_database_ becomes myapp-database-, and the session cookie myapp_session becomes myapp-session. Your app only hits this if it relies on the framework fallback, or if you copy the 13.x skeleton's config/cache.php, config/database.php or config/session.php over your own files, since those now use the hyphenated form.
If the prefix changes in production, every cached value is a miss at once (a cold cache under full traffic) and every user is logged out because the browser sends a cookie name the app no longer reads. The fix is to pin the current values before you deploy:
# .env: keep exactly what Laravel 12 produced for APP_NAME="My App"
CACHE_PREFIX=my_app_cache_
REDIS_PREFIX=my_app_database_
SESSION_COOKIE=my_app_session
Check the real values on the running app first with php artisan tinker and config('cache.prefix'), config('database.redis.options.prefix') and config('session.cookie').
serializable_classes in the cache config
The 13.x skeleton adds 'serializable_classes' => false to config/cache.php. It blocks PHP object unserialization from the cache, which protects you if APP_KEY ever leaks. The catch: plenty of apps cache Eloquent models or collections with Cache::remember(). With this setting, those come back as unusable objects. If your existing config file does not contain the key, the framework applies no restriction, so this bites when you sync config files. Either list the classes you cache, or cache arrays instead of objects:
'serializable_classes' => [
App\Data\DashboardStats::class,
],
Session serialization set to json
The new skeleton sets 'serialization' => 'json' in config/session.php. Switching an existing app from php to json invalidates every active session. Keep php during the upgrade, and plan the switch to json for a quiet hour later, after checking that you do not store objects in the session.
Jumping several majors to Laravel 13
If you are on Laravel 8, 9 or 10, go one major at a time: 8 to 9, 9 to 10, 10 to 11, 11 to 12, 12 to 13, with tests passing and a deploy (at least to staging) between each step. Each major has its own guide on laravel.com, and each raises the PHP floor: Laravel 9 needs PHP 8.0, 10 needs 8.1, 11 and 12 need 8.2, and 13 needs 8.3. Laravel 11 introduced the slimmer application structure, but the guide does not require you to migrate an older app to it.
Rector (opens in a new tab) with the community driftingly/rector-laravel package automates many of the mechanical edits. Its README recommends composer-based sets that read your installed Laravel version:
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([__DIR__.'/app', __DIR__.'/config', __DIR__.'/routes', __DIR__.'/tests'])
->withComposerBased(laravel: true);
composer require --dev rector/rector driftingly/rector-laravel
vendor/bin/rector process --dry-run
Review the dry run, commit it as its own change, then run the tests. Rector rewrites syntax. It does not know about your config files, your packages or your data.
Staging, rollout and rollback
- Staging first, on the same PHP version, the same Redis and the same queue driver as production, with a copy of real data if you can.
- Deploy in a quiet window. Put the app in maintenance mode only if your deploy cannot run without it.
- Run the optimize step:
php artisan optimizeaftercomposer install --no-dev --optimize-autoloader, thenphp artisan reload(or restart Supervisor) so queue workers and Horizon load the new code. Old workers running Laravel 12 code against Laravel 13 vendor files cause errors that look random. - Watch the first hour: error rate, failed jobs, login success, cache hit rate.
- Rollback plan: the previous release directory or commit, the previous
composer.lock, and the same.env. Since Laravel 13 needs no database schema change of its own, rolling back the code is normally enough. Your own migrations are a separate question.
If you deploy to shared hosting rather than a VPS, my guide to deploying Laravel on cPanel or a VPS covers the PHP version switch and queue workers on each.
Post-upgrade checklist
php artisan aboutshows Laravel 13.x and PHP 8.3 or newer- Test suite green, including feature tests for auth, payments and exports
- No references left to
VerifyCsrfTokenorValidateCsrfToken - Every
upsert()call passesuniqueBy CACHE_PREFIX,REDIS_PREFIXandSESSION_COOKIEpinned in every environment- Cache
serializable_classesand sessionserializationdecided on purpose - Queue workers, Horizon, scheduler and Octane restarted on the new release
- Error tracker quiet for 24 hours
- Deprecation log reviewed (set
LOG_DEPRECATIONS_CHANNELto see them)
Once you are stable, it is a good moment to look at response times too. My Laravel performance checklist is a practical next step.
Frequently asked questions
How long does a Laravel 12 to 13 upgrade take?
The official estimate is about ten minutes of code changes, and for a small app with current packages that holds. A production app usually needs a day or two once you count package updates, config review, testing and a staged rollout. Apps several majors behind take longer because each major is its own step.
What PHP version does Laravel 13 need?
Laravel 13 requires PHP 8.3 or newer and supports PHP 8.3, 8.4 and 8.5. Upgrade PHP first while still on Laravel 12, which also supports 8.3 to 8.5, so the two changes ship separately.
Will upgrading to Laravel 13 log out my users?
Only if the session cookie name or session serialization changes. Pin SESSION_COOKIE to its current value in .env and keep 'serialization' => 'php' in config/session.php during the upgrade, and sessions survive.
Can I skip from Laravel 10 straight to 13?
You can change the version constraint in one go, but I do not recommend it. Upgrading one major at a time, with tests between each step, makes every failure traceable to a specific set of changes. Rector and Shift make the intermediate steps quick.
Is Laravel 12 still safe to run?
Yes, for now. Laravel 12 receives security fixes until February 24, 2027, but no longer receives bug fixes. Plan the move to Laravel 13 well before that date.
If you would rather hand this off, I do Laravel upgrades as fixed-scope projects and ongoing PHP and Laravel maintenance, and you work directly with the engineer making the changes.