I Upgraded a Real Laravel 12 App to Laravel 13. Here’s Exactly What Broke and What Didn’t.

The “10-minute upgrade” claim is mostly true. Here’s the asterisk: three quiet config defaults changed that can silently break production. This is the practitioner’s guide — in the order that actually matters.

Taylor said “zero breaking changes” at Laracon EU and he was accurate — for application logic. Your routes, controllers, Eloquent models, and business code almost certainly don’t need a single edit. But “zero breaking changes” only covers the framework itself. It doesn’t cover infrastructure defaults, changed config values, or third-party packages. That’s where the upgrade can quietly catch you.

This is the upgrade I just ran on a real Laravel 12 production application — a SaaS invoicing tool with about 45,000 lines of PHP, a full Pest test suite, Livewire v4, Inertia.js v2, and a handful of Spatie packages. Here’s what happened, step by step, in the order that actually matters.


Before You Start: The One Hard Blocker

php -v

Laravel 13 requires PHP 8.3 minimum. That’s it. That’s the only hard blocker. Everything else is workable. PHP 8.2 is not.

Laravel 13 requires PHP 8.3 or higher as the minimum supported version. If you’re on PHP 8.2, upgrade your runtime first — then come back to this guide.

Check every environment: local, staging, production, CI. The one that trips teams is production servers where the hosting provider controls the PHP version. Check with them before you start.


Step 1: composer why-not — Know Your Blockers Before You Touch Anything

composer why-not laravel/framework:^13.0

Run this first. It shows you exactly which packages are blocking the upgrade and what version they need to be at. Most major packages — Livewire, Inertia.js, Filament and the Spatie family are compatible with Laravel 13. But run this anyway. In my case it flagged one internal package that pinned laravel/framework: ^12.0 — a ten-second fix, but good to know before composer update throws a wall of errors.


Step 2: The Upgrade Itself

# Update composer.json
# Change: "laravel/framework": "^12.0"
# To:     "laravel/framework": "^13.0"
# Also:   "phpunit/phpunit": "^12.0" if you pin PHPUnit

composer update

That’s genuinely most of the work. My project took about four minutes for Composer to resolve and download. Zero errors.

The Boost slash command (the new official way)

There’s a new option I didn’t use this time but would on the next upgrade. You can automate your upgrade using Laravel Boost. Boost is a first-party MCP server that provides your AI assistant with guided upgrade prompts — once installed in any Laravel 12 application, use the /upgrade-laravel-v13 slash command in Claude Code, Cursor, OpenCode, Gemini, or VS Code to begin the upgrade to Laravel 13. It requires Boost 2.0 installed. If you have it, use it — it handles the config file diffs and flags exactly what needs reviewing.


Step 3: Clear Everything

php artisan config:clear
php artisan route:clear
php artisan view:clear
php artisan cache:clear

Always do this before running your test suite after a framework upgrade. Stale compiled files will give you misleading failures.


Step 4: The Four Things That Actually Need Attention

This is where the real work is. None of these are framework-level breaking changes in the formal sense. All of them can quietly break a production app.

4a. Cache prefix defaults changed — check this first

This one is the sneakiest. If you’ve never explicitly set CACHE_PREFIX or SESSION_COOKIE in your .env, those values will silently change after the upgrade.

The specific changes:

// Laravel 12
Str::slug(env('APP_NAME'), '_') . '_cache_'      // "my_app_cache_"
Str::slug(env('APP_NAME'), '_') . '_database_'   // "my_app_database_"
Str::slug(env('APP_NAME'), '_') . '_session'      // "my_app_session"

// Laravel 13
Str::slug(env('APP_NAME')) . '-cache-'            // "my-app-cache-"
Str::slug(env('APP_NAME')) . '-database-'         // "my-app-database-"
Str::snake(env('APP_NAME')) . '_session'          // "my_app_session" (same)

If your app is running Redis or database-backed cache in production and you’ve never explicitly set CACHE_PREFIX, your cache keys will change silently on deploy. Users get logged out. Previously cached expensive queries miss. Rate limiters reset. Sessions are invalidated.

The fix: Explicitly set these in .env before upgrading:

# .env — lock these down BEFORE upgrading
CACHE_PREFIX=my_app_cache_         # your current value
REDIS_PREFIX=my_app_database_      # your current value
SESSION_COOKIE=my_app_session      # your current value

Run php artisan tinker on your Laravel 12 app first and check config('cache.prefix'), config('database.redis.options.prefix'), and config('session.cookie') to get your exact current values. Pin them in .env before running composer update.

4b. serializable_classes defaults to false — security change

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 — 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.

Check your codebase:

# Find all Cache::put and Cache::remember calls
grep -rn "Cache::\(put\|remember\|forever\|add\)" app/

If any of them cache objects (not just arrays/scalars), add those classes to your config:

// config/cache.php
'serializable_classes' => [
    App\Data\CachedDashboardStats::class,
    App\Support\PricingSnapshot::class,
],

In my app I found two Cache::remember calls storing DTOs. Added them to the list. Five minutes of work. If I’d deployed without noticing, those cache reads would have thrown UnserializationFailedException in production.

4c. Pagination view names renamed

If your application references the old pagination view names directly, update those references.

// Laravel 12
'pagination::default'         // → 'pagination::bootstrap-3'
'pagination::simple-default'  // → 'pagination::simple-bootstrap-3'

If you use $results->links() without passing a view name — and most apps do — this doesn’t affect you at all. It only matters if you’ve hardcoded the view name as a string anywhere. Search your codebase:

grep -rn "pagination::" app/ resources/

I had zero hits. Most apps won’t either.

4d. Container::call nullable behaviour changed

Container::call now respects nullable class parameter defaults when no binding exists, matching constructor injection behaviour introduced in Laravel 12.

// Laravel 12: passes a Carbon instance
$container->call(function (?Carbon $date = null) { return $date; });
// returns Carbon instance

// Laravel 13: passes null (respects the default)
$container->call(function (?Carbon $date = null) { return $date; });
// returns null

This only matters if you’re manually calling $container->call() with nullable typed parameters and relying on the old binding behaviour. Search for it:

grep -rn "app()->call\|container->call\|Container::call" app/

I had one hit — a test helper. Trivial fix. Most apps won’t have this at all.


Step 5: Custom Contracts (If You Have Them)

If you maintain custom implementations of Laravel contracts — your own cache store, your own dispatcher, your own event dispatcher — check the upgrade guide for new required methods.

The three additions in Laravel 13:

// If you implement Illuminate\Contracts\Bus\Dispatcher:
public function dispatchAfterResponse($command, $handler = null);

// If you implement any cache contract:
public function touch(string $key, DateTimeInterface|DateInterval|int $seconds = 0): bool;

// If you implement Illuminate\Contracts\Pagination\Paginator:
// (check the upgrade guide — new method signature)

Most apps don’t have custom contract implementations. If you do, you’ll know.


Step 6: Run Your Test Suite

php artisan test
# or: ./vendor/bin/pest

In my case: 312 tests, all green. Total upgrade time including all the config work above: 23 minutes.

The official estimate of “10 minutes” is accurate for a standard app with no custom contracts and explicit cache config. The extra 13 minutes for me was the serializable_classes audit and pinning the cache prefixes.


What I Got For Free (No Code Changes Required)

After the upgrade, everything that shipped in Laravel 13 is just available. No action needed:

  • PHP Attributes on models — optional, use when touching the file
  • Cache::touch() — immediately available
  • Http::afterResponse() — immediately available
  • Str::plural(prependCount: true) — immediately available
  • wantsMarkdown() / acceptsMarkdown() — immediately available
  • Reverb database driver — set REVERB_SCALING_DRIVER=database whenever ready
  • PasskeysFeatures::passkeys() whenever you’re ready to implement

The upgrade doesn’t require you to adopt any of these. They’re available when you want them.


The Three Tools Worth Knowing About

composer why-not — run before anything else. Tells you what’s blocking the upgrade.

Laravel 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 above covers everything.

Laravel Boost /upgrade-laravel-v13 — the new first-party AI-guided path. If you have Boost 2.0 installed, use this instead of manual config diffs. It reads your actual codebase and tells you exactly what needs changing.


Should You Upgrade Now?

Laravel 12 receives bug fixes until August 2026 and security fixes until February 2027. You’re not on borrowed time yet.

The honest answer:

  • New projects: Start on 13. No reason not to.
  • Production apps with good test coverage and PHP 8.3+: Upgrade now. It’s genuinely low-risk. Pin your cache config first.
  • Production apps on PHP 8.2: Upgrade PHP first, then come back.
  • Apps that skipped 10, 11, or 12: You need to go version by version. Go 10 → 11 → 12 → 13. Each step has its own migration path, and skipping versions compounds breaking changes significantly.

The “10-minute upgrade” claim is mostly true. The asterisk is: three config defaults changed quietly, and if you don’t know to look for them, they’ll bite you in production on deploy day, not during testing.

Now you know to look.


Follow for weekly deep-dives on Laravel, PHP, Vue.js, and the agentic stack.

Leave a Reply

Your email address will not be published. Required fields are marked *