{} CodeLift

We Actually Ran the Laravel 12 to 13 Upgrade

There is no shortage of Laravel 13 upgrade guides. The problem is that most of the warnings in them were written without running the upgrade.

Pub 2026-07-23 Verified 2026-07-23 Upd 2026-08-12

Verification environment

  • Baseline Laravel 12.64.0
  • Upgraded Laravel 13.21.1
  • PHP 8.4.23 (identical on both sides)
  • Queue driver database (SQLite)
  • Cache store database (SQLite)
  • Claims tested 4 — three did not reproduce
  • OS Docker Desktop (php:8.4-cli-bookworm)

There is no shortage of Laravel 13 upgrade guides. The problem is that most of the warnings in them were written without running the upgrade.

This is a record of building a real Laravel 12 application in Docker and upgrading it to a real Laravel 13, measuring what actually happens. Of the claims tested, three did not reproduce. Two genuine obstacles turned up that nobody documents.

# Commonly published claim Measured result
1 Laravel 13 adds a php artisan upgrade command Does not exist
2 The default cache prefix changes, orphaning existing keys Unchanged
3 Jobs queued under Laravel 12 fail on a Laravel 13 worker Did not reproduce
4 Near-zero breaking changes; composer update and you're done in 10 minutes Fails if you do exactly that

The scripts are published at laravel13-upgrade-lab. You can reproduce every result below.

Environment

PHP is pinned to 8.4 on purpose. Both Laravel 12 and 13 support it, which isolates the framework version as the only variable.

Baseline Laravel 12.64.0
After upgrade Laravel 13.21.1
PHP 8.4.23 (identical on both sides)
Queue driver database (SQLite)
Cache store database (SQLite)
Runtime Docker Desktop / php:8.4-cli-bookworm

The method: build a Laravel 12 app, leave real state in it — serialized queue jobs and cache entries — then upgrade that same app in place. Carrying the state across is the whole point.

1. php artisan upgrade does not exist

Several guides state that Laravel 13 ships a php artisan upgrade command for checking compatibility.

Checked on both Laravel 12.64.0 and Laravel 13.21.1:

$ php artisan upgrade --help
RESULT: does NOT exist on Laravel 13

$ php artisan list --raw | grep -i upgrade
(none)

Not a single artisan command matches upgrade. Any procedure built around this command stops at step one.

2. The cache prefix did not change

The warning goes: the default cache prefix changes, so unless you pin CACHE_PREFIX in .env, existing cache keys become unreachable.

First, the upgraded app. Values written under Laravel 12 read back fine:

prefix now      : laravel-cache-
codelift:probe  : 'written-on-12'
codelift:forever: array ( 'v' => 12 )

That alone proves nothing. cache.prefix is defined in config/cache.php, which is owned by the application, not the framework. An upgrade never overwrites it — so "the Laravel 12 config simply survived" is an equally good explanation.

So the config was compared against a freshly created Laravel 13 app.

Upgraded app (config/cache.php:115):

'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),

Fresh Laravel 13 app (config/cache.php:121):

'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),

The expressions are byte-identical and both resolve to laravel-cache-. The differing line numbers are just surrounding comment volume. The claim does not hold.

3. Laravel 12 jobs run fine on Laravel 13

This is the most repeated warning: zero breaking changes applies to application code, not to serialized data sitting in the queue, so drain the queue before upgrading.

A first attempt with a Collection payload processed without incident. One passing case is not enough to call a claim false, so the test was rebuilt around the payload shapes most likely to break.

The setup: a real Laravel 12.64.0 serializes the jobs, the SQLite file is carried over wholesale, and a real Laravel 13.21.1 worker drains it. The job class is byte-identical on both sides.

Payload Result
Eloquent model (SerializesModels) ✅ processed
CarbonImmutable ✅ processed
Nested array ✅ processed
Bus::chain, two steps ✅ processed
Illuminate\Support\Collection ✅ processed
  App\Jobs\PayloadProbe .......................... RUNNING
[ok] kind=model-only user=probe@example.test at=- extra=[]
  App\Jobs\PayloadProbe .................... 419.72ms DONE
  ...
jobs remaining : 0
failed         : 0

All five shapes succeeded, zero failures. In a default configuration, the claim does not reproduce.

What this test does not cover

Not tested. Conclusions could differ under these conditions:

  • Redis / SQS drivers, or Horizon
  • Encrypted payloads
  • Jobs referencing classes the application deleted or renamed

The third one genuinely breaks. But that is an old worker process meeting new code — a deploy-ordering problem, not something specific to Laravel 13. It happens, or doesn't, identically on 11→12. Draining remains a sound practice; the reason given for it is simply wrong.

4. The real blocker is laravel/tinker

"Update composer.json and run composer update, about 10 minutes" does not hold. Bumping the framework alone fails dependency resolution.

$ composer require laravel/framework:^13.0 --update-with-all-dependencies

Your requirements could not be resolved to an installable set of packages.

  Problem 1
    - Root composer.json requires laravel/framework ^13.0 -> satisfiable by laravel/framework[v13.12.0, ..., v13.21.1].
    - laravel/tinker is locked to version v2.11.1 and an update of this package was not requested.
    - laravel/tinker v2.11.1 requires illuminate/support ^6.0|...|^12.0

composer exit: 2

laravel/tinker v2 caps at illuminate/support ^12.0. Even with --update-with-all-dependencies, packages not required at the root are not candidates for update, so the conflict cannot resolve.

Naming both fixes it:

$ composer require "laravel/framework:^13.0" "laravel/tinker:^3.0" \
    --update-with-all-dependencies
composer exit: 0

Note that the error never says "upgrade tinker." It surfaces as a replace conflict over illuminate/support and spatie/once, which makes the actual cause hard to reach.

5. The PHP constraint in composer.json is left stale

This one appears in no guide. Look at the state after a successful upgrade:

Value
PHP required by laravel/framework ^8.3
PHP declared in the app's composer.json ^8.2

composer require does not rewrite the application's own require.php. And because ^8.2 and ^8.3 overlap, composer does not treat the contradiction as an error.

You end up with an application still advertising PHP 8.2 support. If your development machine runs 8.3 or later, nothing surfaces. It breaks the moment it is deployed to a PHP 8.2 host.

Fix it by hand:

"require": {
    "php": "^8.3",

What the upgrade actually takes

composer require "laravel/framework:^13.0" "laravel/tinker:^3.0" \
  --update-with-all-dependencies

Then edit require.php in composer.json to ^8.3.

Draining the queue beforehand is not required on account of Laravel 13. It is required if you are changing job classes in the same deploy — which is true of every version.

FAQ

Q. Does Laravel 13 ship a php artisan upgrade command?

No. Checked on both Laravel 12.64.0 and 13.21.1 — not a single artisan command matches upgrade. Any procedure built around it stops at step one.

Q. Will jobs queued under Laravel 12 fail on a Laravel 13 worker?

In a default configuration, it did not reproduce. Jobs serialized by a real Laravel 12.64.0 were carried over with the SQLite file and drained by a real 13.21.1 worker: Eloquent models, CarbonImmutable, nested arrays, Bus::chain and Collection — five shapes, all processed, zero failures.

Not tested: Redis / SQS, Horizon, encrypted payloads. Jobs referencing classes the application has deleted or renamed genuinely do break, but that is a deploy-ordering problem, not something specific to Laravel 13.

Q. Does the default cache prefix change in Laravel 13?

No. Comparing config/cache.php in the upgraded app against a freshly created Laravel 13 app, the expressions are identical and both resolve to laravel-cache-. Values written under Laravel 12 still read back fine.

Q. Can I upgrade with just composer require laravel/framework:^13.0?

No — it fails to resolve. laravel/tinker v2 caps at illuminate/support ^12.0, and even with --update-with-all-dependencies a package not required at the root is not a candidate for update, so the conflict cannot clear.

Naming both fixes it:

composer require "laravel/framework:^13.0" "laravel/tinker:^3.0" --update-with-all-dependencies

Q. Does the upgrade update the PHP requirement in composer.json?

No. composer require does not rewrite the application's own require.php, so the framework requires ^8.3 while composer.json still declares ^8.2. The ranges overlap, so composer never reports it.

If your machine runs 8.3 or later you will not notice — it breaks the moment it reaches a PHP 8.2 host. Fix it by hand.

Reproducing this

Every claim here is transcribed from execution logs. You can get the same output locally.

git clone https://github.com/codelift-dev/laravel13-upgrade-lab
cd laravel13-upgrade-lab
docker compose build
docker compose run --rm lab bash 01-baseline-l12.sh
docker compose run --rm lab bash 03-upgrade-correctly.sh
docker compose run --rm lab bash 05-queue-payloads.sh

Everything runs inside Docker. No PHP or Composer needed on the host.

These findings are current as of Laravel 13.21.1. If a future patch relaxes the laravel/tinker constraint, point 4 will stop applying, and this article will be updated.

Related articles