DEV Community

Cover image for Laravel 12 to 13 Upgrade Guide: Zero Breaking Changes Doesn't Mean Zero Work
Hafiz
Hafiz

Posted on • Originally published at hafiz.dev

Laravel 12 to 13 Upgrade Guide: Zero Breaking Changes Doesn't Mean Zero Work

Originally published at hafiz.dev


Laravel 13 dropped on March 17. The official messaging is "zero breaking changes," and for most apps that's pretty close to the truth. But close isn't the same as zero. There are a handful of defaults that changed quietly, one PHP requirement that will block your upgrade entirely if you ignore it, and a new first-party tool that makes the whole process significantly faster. This guide covers all of it.

This isn't a feature tour. If you want before/after examples of PHP Attributes or Cache::touch(), I've already covered those. This post is only about the upgrade process itself, in the order that matters.

Why "Zero Breaking Changes" Isn't the Full Story

The phrase is technically accurate for application-level code. Your routes, controllers, Eloquent models, and business logic almost certainly don't need touching.

But "breaking changes" in the Laravel changelog only covers the framework itself. It doesn't cover your infrastructure, your .env defaults, or your third-party packages. That's where things can catch you off guard.

Specifically:

  • If your server runs PHP 8.2, your upgrade fails before it starts
  • If you've never explicitly set CACHE_PREFIX or SESSION_COOKIE in your .env, those values will silently change after the upgrade
  • If you reference pagination view names as strings anywhere in your codebase, those names changed
  • If you have custom cache store implementations, a new contract method is now required

None of these are application logic. All of them can quietly break a production app.

So when Laravel says smooth upgrade, they're right. But smooth still requires knowing what to look at. Let's go through it in the order that matters.

Step 1: PHP 8.3 First

This is the only hard blocker. Do not change your composer.json until this is sorted.

php -v
Enter fullscreen mode Exit fullscreen mode

If you're on PHP 8.2 or earlier, you need to upgrade your server runtime before touching anything else. On Ubuntu/Debian with Ondrej's PPA:

sudo add-apt-repository ppa:ondrej/php
sudo apt update
sudo apt install php8.3 php8.3-fpm php8.3-mbstring php8.3-xml php8.3-curl php8.3-mysql
Enter fullscreen mode Exit fullscreen mode

Don't forget to update your Nginx or Apache config to point at the new FPM socket. And update composer.json's PHP constraint while you're there:

"php": "^8.3"
Enter fullscreen mode Exit fullscreen mode

If you're on Forge or Vapor, it's a one-click PHP version switch in the server panel. If you're on a shared host still defaulting to PHP 8.1, that's the actual problem to solve first. Docker is a clean escape hatch here if you need runtime flexibility without touching production infrastructure directly.

Once you've confirmed php -v returns 8.3+, move to the next step.

Step 2: Pin Your .env Before Touching Composer

This is the step most upgrade guides skip. It's also the one most likely to cause silent, user-facing problems on deploy day.

Laravel 13 changed two default naming patterns for cache and session:

Cache and Redis key prefixes now use hyphenated suffixes. The generated default changed from myapp_cache_ to myapp-cache-.

Session cookie names now use Str::snake() instead of Str::slug() with underscores.

Here's the critical part: this only affects apps that have never explicitly configured these values. If your config/cache.php and config/session.php already reference .env variables that you've set explicitly, nothing changes for you.

But if you're relying on framework-generated fallback values (more common than you'd think, especially on apps that were scaffolded quickly), every active user session will be invalidated the moment you deploy. Their cookie won't match the new session cookie name. And your cached data becomes unreachable because the key prefix changed.

Fix this before running the upgrade. Check what your app is currently generating:

php artisan tinker
>>> config('cache.prefix')
>>> config('session.cookie')
Enter fullscreen mode Exit fullscreen mode

Then pin those exact current values in your .env:

CACHE_PREFIX=myapp_cache_
REDIS_PREFIX=myapp_cache_
SESSION_COOKIE=myapp_session
Enter fullscreen mode Exit fullscreen mode

Once they're explicitly set, the change in framework defaults becomes irrelevant. Your app keeps using whatever you've defined, regardless of what the framework generates as a fallback.

This two-minute step prevents a lot of pain.

Step 3: The Composer Update

With PHP sorted and .env pinned, the actual upgrade is a single command:

composer update laravel/framework --with-all-dependencies
Enter fullscreen mode Exit fullscreen mode

The --with-all-dependencies flag matters. Without it, transitive dependencies can stay on older versions and cause conflicts that are annoying to debug. Use it.

Not sure what's blocking you before you run it? Check first:

composer why-not laravel/framework:^13.0
Enter fullscreen mode Exit fullscreen mode

This tells you exactly which packages don't support Laravel 13 yet. For most major packages (Filament, Livewire, Inertia, Spatie) support landed on or shortly after March 17. But some smaller packages might need a day or two to catch up, and it's better to know that before you're halfway through an upgrade on a branch.

If a package you rely on isn't compatible yet, you've got two options: wait for the maintainer to tag a new release, or hold the upgrade until then. Don't force it. A "^12.0|^13.0" constraint in your own packages is the right pattern to use while the ecosystem catches up, but that's for packages you control.

After the update runs successfully:

php artisan optimize:clear
php artisan config:clear
php artisan cache:clear
Enter fullscreen mode Exit fullscreen mode

Then run your test suite. Don't skip this step even if you're confident. The tests will catch the things this guide doesn't know about your specific app.

The Breaking Changes That Can Actually Bite You

The official upgrade guide documents a fair number of changes, but most of them only apply to edge cases. Here are the ones most likely to affect a real production application.

Cache Serialization Hardening

Laravel 13 adds a serializable_classes option to config/cache.php, defaulting to false. This is a security change. It prevents PHP deserialization gadget chain attacks if your APP_KEY ever leaks.

For most apps this has zero impact because you're caching arrays and scalar values. But if you're caching actual PHP objects (Eloquent model instances, DTOs, anything like that), you need to explicitly allow them:

'serializable_classes' => [
    App\Data\CachedDashboardStats::class,
    App\Support\CachedPricingSnapshot::class,
],
Enter fullscreen mode Exit fullscreen mode

If you're unsure whether your app caches objects, search for Cache::put and Cache::remember calls and check what's being stored. Primitives and arrays are fine. Objects need to be on the list.

Pagination View Names

This one only bites you if you're passing pagination view names as strings anywhere directly. The names changed:

// Laravel 12
'pagination::default'
'pagination::simple-default'

// Laravel 13
'pagination::bootstrap-3'
'pagination::simple-bootstrap-3'
Enter fullscreen mode Exit fullscreen mode

If you use $results->links() with no arguments, nothing changes. But if you have anything like $results->links('pagination::default') hardcoded somewhere, update those strings.

MySQL DELETE with JOIN

If your app has query builder calls that combine DELETE, JOIN, and either ORDER BY or LIMIT, the generated SQL changed. In previous versions the ORDER BY and LIMIT clauses were silently ignored on joined deletes. Laravel 13 includes them, which can throw a QueryException on MySQL/MariaDB setups that don't support this syntax combination.

Run a quick search:

grep -r "->join(" app/ --include="*.php" | grep -v ".test."
Enter fullscreen mode Exit fullscreen mode

Then check whether any of those query chains also call ->delete(). It's probably rare in your codebase. But it's worth 30 seconds to verify.

Custom Contract Implementations

If you've built a custom cache store driver, the Illuminate\Contracts\Cache\Store contract now requires a touch method. A few other contracts also gained new methods. If you don't maintain any custom framework interface implementations, skip this. If you do, check the official upgrade guide for the complete list.

The Fast Path: Laravel Boost and /upgrade-laravel-v13

If you already have Laravel Boost configured with your AI editor, there's a dedicated slash command worth knowing about:

/upgrade-laravel-v13
Enter fullscreen mode Exit fullscreen mode

Run it in Claude Code, Cursor, OpenCode, or VS Code. Boost is a first-party Laravel MCP server, and this command gives your AI assistant a guided upgrade prompt that knows the official breaking change documentation. It scans your codebase for patterns that match the documented changes and flags them.

It won't upgrade PHP for you. It won't fix the .env defaults issue (that's environment-level, not code-level). But for the contract changes, event listener signature updates, and model boot method issues, it does the scan automatically instead of you manually grep-ing through a large codebase.

Think of it as an automated first pass that catches the obscure stuff. You still do a final review, but it does the heavy lifting.

What to Adopt Now vs What to Skip

Upgrading to Laravel 13 and adopting its new features are two separate decisions. Don't conflate them.

After the upgrade, just make sure everything still works. That's the only goal for day one.

Once that's confirmed, here's how I'd think about the new features:

Cache::touch() is a quick win. If your app extends cache TTLs anywhere (rate limiting, user session renewal, anything like that), you're currently doing a get + put round trip. Cache::touch() collapses that into a single EXPIRE command on Redis. Low risk, tiny refactor, immediate improvement.

// Before: two round trips, full value transferred
$value = Cache::get('rate_limit_key');
Cache::put('rate_limit_key', $value, now()->addMinutes(10));

// After: single EXPIRE command, value never leaves the server
Cache::touch('rate_limit_key', now()->addMinutes(10));
Enter fullscreen mode Exit fullscreen mode

PHP Attributes for models, jobs, and commands are optional. The old property-based syntax still works and there's no deprecation timeline. For a new project, start with Attributes. For an existing codebase, migrate gradually or not at all. There's no urgency.

The Reverb database driver (no Redis required for WebSockets) is promising. For smaller apps that don't want to provision Redis just for WebSocket scaling, this removes a real infrastructure dependency. I'd run benchmarks at your expected connection count before switching in production, but for a new real-time feature it's worth starting with the database driver.

Passkeys via Fortify are now built-in for new Laravel 13 installations. For existing apps it's still a migration project, not a one-liner. Good time to evaluate it for your next auth system refresh.

The AI SDK is stable. But read the stable release notes before dropping it into production. There were config changes between beta and stable that matter if you were running the beta.

FAQ

Do I need to upgrade now?

No. Laravel 12 receives bug fixes until August 2026 and security fixes until February 2027. If you're starting a new project today, use 13. If you're maintaining an active production app, plan it properly and upgrade when you have test coverage to back it up.

Will my packages break?

Run composer why-not laravel/framework:^13.0 before you start. This shows you exactly which packages are blocking the upgrade and what version they need to be at. Most major packages are already compatible.

Should I use Laravel Shift or do it manually?

Shift costs $29 per upgrade and handles around 80-90% of the mechanical changes. For large codebases the time savings justify the price. For smaller apps the manual approach in this guide covers everything. Your call.

What if I'm on Laravel 10 or 11?

You need to upgrade version by version: 10 to 11, then 11 to 12, then 12 to 13. You can't skip versions. Each major release has its own migration path and skipping them compounds the breaking changes significantly.

How long does this actually take?

Depends on your PHP version situation and how many of the edge cases apply to you. If you're already on PHP 8.3 and don't have custom contract implementations, the upgrade itself can be done in under an hour. The PHP upgrade on a production server is the wildcard.

Go Upgrade

Laravel 13 really is one of the smoothest major upgrades the framework has ever shipped. The "zero breaking changes" messaging isn't wrong. But smooth upgrades still require knowing which three or four things to check before you run composer update.

Pin your .env values first. Upgrade PHP before anything else. Run the Boost slash command if you have it. Then do the composer update and run your tests. That order matters.

Top comments (0)