Skip to content

Laravel 12 to 13: The Config Sync That Logs Users Out and Breaks the Cache

· 11 min read ·

I upgraded a Laravel 12 app to 13 in two stages and recorded what each stage did to its cache, its sessions and its queue. The app came from the laravel/laravel 12.0.11 skeleton (June 2025), ran framework 12.69.2, and used Redis for all three. Before the upgrade it held 14 cached values, one logged-in visitor and one queued job.

composer update to 13.33.0 changed none of that. All 14 values read back, the visitor stayed logged in, the job waited. Then I copied the 13 skeleton's config/cache.php, config/session.php and config/database.php over the app's, one file at a time. Every cached key missed, the visitor was logged out, the job vanished from the worker's view, and a cached Eloquent collection came back as __PHP_Incomplete_Class.

Calling a method on that collection threw this:

The script tried to call a method on an incomplete object. Please ensure that the class definition "Illuminate\Database\Eloquent\Collection" of the object you are trying to operate on was loaded _before_ unserialize() gets called or provide an autoloader to load the class definition

If you only need the fix: pin CACHE_PREFIX, REDIS_PREFIX and SESSION_COOKIE to the values your app uses today, set serializable_classes before you sync anything, and keep sessions on php for the upgrade. The order I verified is in the last section. Already on 13 and seeing __PHP_Incomplete_Class? Add the missing classes to serializable_classes, or set it to true for now: what's already cached reads correctly again without a flush.

The measurements below ran on Laravel 12.69.2 and 13.33.0, PHP 8.5.10 and Redis 8.10.1.

Why composer update changed nothing

The upgrade guide estimates 10 minutes and rates the prefix and session changes "Low". For this app's composer update, the rating held. Laravel 13 put 'serializable_classes' => false and json sessions only in the skeleton's config files.

The framework's fallback config/cache.php has no serializable_classes key. CacheManager reads the option as $this->app['config']['cache.serializable_classes'] ?? null, and the Redis, database, file and DynamoDB stores pass allowed_classes to unserialize() only when that value isn't null. Sessions fall back to php serialization the same way.

So the error shows up in two places: in fresh Laravel 13 installs, whose skeleton ships false (#60714 reports one), and in upgraded apps on the day someone copies the new files in. The option itself isn't new: it arrived in 12.53.0 (#58911) in February 2026 with no default, so you can set it before you upgrade.

The prefixes did change in the framework's own fallbacks, from _cache_ and _database_ to -cache- and -database- after the app name, so an app whose config files don't set a prefix gets the new ones from composer update alone. This app's files set them, and a config file's values beat the framework's.

config/cache.php moves the prefix and blocks cached objects

The diff looks harmless: new storage and failover stores, the serializable_classes block, and a prefix line that swaps underscores for hyphens. That last line shows up only if your file predates laravel/laravel v12.1.0 (July 3, 2025), as the Laravel 11 skeleton's files do.

A hyphen for an underscore is a different key in Redis. After the copy, all 14 values missed, and the old entries stay under laravel_database_laravel_cache_* until their TTL runs out. Other things live under that prefix too:

  • The Redis session driver stores sessions through the cache store, so a file called cache.php logged the visitor out. With the database drivers, the same prefix change left the session alone and made all 14 database cache entries miss.
  • After the change, php artisan queue:restart wrote laravel_database_laravel-cache-illuminate:queue:restart, and a worker started under the old prefix ignored it for the 8 seconds I waited. It stopped the moment I restored the old prefix and ran queue:restart again, so a deploy that changes the prefix needs another way to restart its workers. Redis cache locks are named with the same prefix, so the keys of Laravel's four locks move with it.

The copied file also sets 'serializable_classes' => false. I pinned the old prefix in .env so the old entries were found again, and 11 of the 14 values came back as __PHP_Incomplete_Class. Only a plain array, a model's toArray() output and an array holding an enum survived.

Cache::remember() makes this worse. It only calls your closure when get() returns null, and an incomplete object counts as a hit. I tested this call:

$products = Cache::remember('products:featured', 86400, function () {
    return Product::with('category')->orderBy('id')->take(3)->get();
});

Before I pinned the prefix, the first call after the copy missed, ran the closure and returned real models. It also stored them. The next call, in a new process, got __PHP_Incomplete_Class back without running the closure, and every later call gets the same for the rest of those 86,400 seconds. With the old prefix pinned, the closure didn't run even once. By default nothing throws at the cache call, and the error appears wherever the value gets used.

From 13.5.0 the framework can report it at the cache call instead. In AppServiceProvider::boot(), with the Cache and Log facades imported:

Cache::handleUnserializableClassUsing(function (string $key, ?string $class) {
    Log::warning("Cache key [{$key}] returned [{$class}], which serializable_classes doesn't allow.");
});

It logged a DTO cached on its own and stayed silent for the same DTO inside an array: it only checks the top-level value get() returns.

Which classes does serializable_classes need?

PHP's allowed_classes matches exact class names: listing a class admits neither its parent nor its children, and every nested object needs its own entry. I serialized each payload, listed the classes inside it, and then removed them one at a time to confirm each was needed:

What you cache Classes to allow
Product::first() App\Models\Product
Product::take(3)->get() Illuminate\Database\Eloquent\Collection, App\Models\Product
Product::with('category')->get() (belongsTo) Eloquent\Collection, Product, App\Models\Category
Product::with('tags')->get() (belongsToMany) Eloquent\Collection, Product, App\Models\Tag, Illuminate\Database\Eloquent\Relations\Pivot
Product::paginate(3) Illuminate\Pagination\LengthAwarePaginator, Eloquent\Collection, Product
Product::simplePaginate(3) Illuminate\Pagination\Paginator, Eloquent\Collection, Product
Product::pluck('name', 'id'), collect([1, 2, 3]) Illuminate\Support\Collection
new DashboardStats(orders: 12, revenue: 3400) App\Data\DashboardStats
now() Illuminate\Support\Carbon
json_decode('{"rate":1.08}') stdClass
['status' => ProductStatus::Active] nothing

The model needs no Carbon entry, although it has timestamps and an enum cast. Even after I read created_at and status before caching it, the payload held no Carbon and no enum: Laravel computes date and enum casts on each read and doesn't store them on the model. Enums need nothing anyway, because PHP 8.5.10 restores them without checking allowed_classes. And pluck() returns a Support\Collection, which listing Eloquent\Collection doesn't cover.

Fixing the list heals what's already cached. The stored bytes never change, so after I set a list of six classes, entries written while the app was on Laravel 12 read as real models again, no flush needed. The values whose classes I'd left out stayed broken. If you need a stopgap, 'serializable_classes' => true restores 12's behaviour for everything.

Your list depends on what your app caches, and packages count: laravel-model-caching merged a fix for this in April 2026. Rather than guess, I logged the classes in every cache hit while still on 12. In AppServiceProvider (the use lines go at the top of the file):

use Illuminate\Cache\Events\CacheHit;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;

public function boot(): void
{
    Event::listen(function (CacheHit $event) {
        preg_match_all('/[OC]:\d+:"([^"]+)"/', serialize($event->value), $matches);

        foreach (array_unique($matches[1]) as $class) {
            Log::info("cached class: {$class}");
        }
    });
}

It writes one log line per class per hit. Then count them:

grep -o 'cached class: .*' storage/logs/laravel.log | sort | uniq -c

On the scratch app, reading the 14 values printed 11 classes. The listener serializes every hit, so leave it on only as long as it takes to cover your cache's read paths. It can over-report: with Redis sessions, class names inside a stored session string show up too, and those don't need to be listed.

config/session.php logs everyone out, and rolling back doesn't undo it

Two lines change here: the cookie name (laravel_session becomes laravel-session, for files older than laravel/laravel v12.3.0 of August 3, 2025) and a new 'serialization' => 'json'. Either one alone logged the visitor out. With the new name, the browser keeps sending a cookie the app no longer reads. With the old name pinned, json_decode() can't read a payload written by serialize(), so the session starts empty.

The rollback is worse. Two visitors logged in under php. I switched to json, only visitor A made a request, and I switched back. B was still logged in. A wasn't: that one request had overwritten A's session, under the same id, with a fresh JSON payload that php can't read either. Anyone who visits while json is live stays logged out after you revert.

json also changes what comes back. A Carbon instance stored in the session returned as the string "2026-09-23T12:30:56.390544Z", and a DTO as an array; the validation error bag is the only object the framework rebuilds. And one invalid UTF-8 byte in a session value costs the whole session. I stored a query string holding caf%E9 (Latin-1 é) in the session: under json the logged-in visitor was logged out, under php nothing happened. Your own code doesn't have to store it: a redirect with ->withInput(), which is what a failed validation returns, logged the visitor out the same way when the input carried that byte. PR #61652 proposed a fix and was closed unmerged on September 21, 2026.

Taylor Otwell explained the default in PR #60346: "We didn't update it for old applications because it is a breaking change, and applications are secure if their application key is secure." The upgrade guide says to keep php if sessions should survive. I'd keep php for the upgrade and move to json later as its own deploy, after checking what your app puts in the session.

config/database.php hides jobs queued in Redis

For files older than v12.1.0, the REDIS_PREFIX default goes from laravel_database_ to laravel-database-. That prefix sits in front of the queue, cache and session keys. After the copy, Queue::size() went from 1 to 0 while the job sat in Redis under laravel_database_queues:default, and queue:work --once ran nothing. The cache missed again too, even with CACHE_PREFIX pinned, and the Redis session went with it: the visitor was logged out. Pinning REDIS_PREFIX brought all of it back, and the worker ran the job.

The order that kept everything working

I repeated the upgrade on a fresh copy of the same Laravel 12 app. This order took it to 13.33.0 with all three files synced, the visitor still logged in, 14 of 14 cached values usable and the queued job processed.

First, on 12, read the values your app uses today:

php artisan config:show cache.prefix
php artisan config:show database.redis.options.prefix
php artisan config:show session.cookie

Each prints the value in effect, whether it comes from .env, your config file or the framework's fallback. Pin them in .env; mine were the old defaults:

CACHE_PREFIX=laravel_cache_
REDIS_PREFIX=laravel_database_
SESSION_COOKIE=laravel_session

With those in place, the file sync can't move a key or rename the cookie. Next, still on 12.53 or later, add serializable_classes to your current config/cache.php as a top-level key, with the classes the listener printed. Mine:

'serializable_classes' => [
    App\Data\DashboardStats::class,
    App\Models\Category::class,
    App\Models\Product::class,
    App\Models\Tag::class,
    Illuminate\Database\Eloquent\Collection::class,
    Illuminate\Database\Eloquent\Relations\Pivot::class,
    Illuminate\Pagination\LengthAwarePaginator::class,
    Illuminate\Pagination\Paginator::class,
    Illuminate\Support\Carbon::class,
    Illuminate\Support\Collection::class,
    stdClass::class,
],

Deploy that on its own, so a class you missed shows up before the framework upgrade does; true switches the check off in one line. Then:

  1. composer update to 13.
  2. Copy the skeleton's files, put your list back into cache.php (the copy resets it to false), and set 'serialization' => 'php' in session.php.
  3. Move sessions to json later, in its own deploy, when one round of logins is acceptable.

The table above covers what I cached. If your app needed a class it doesn't list, tell me which one and what cached it, and I'll add it.

Common questions

Why do cached models come back as __PHP_Incomplete_Class in Laravel 13?
The Laravel 13 skeleton's config/cache.php sets 'serializable_classes' => false, so the Redis, database, file and DynamoDB cache stores restore cached objects with no classes allowed. Models, collections, paginators, Carbon dates and stdClass objects come back as __PHP_Incomplete_Class. List the classes your app caches, or set the option to true as a stopgap.
Does composer update to Laravel 13 break the cache or sessions?
Not when your own config files set the cache and Redis prefixes. The framework's fallback config has no serializable_classes key and keeps php session serialization, and a config file's own values beat the framework's. In a test upgrade of such an app from 12.69.2 to 13.33.0, all 14 cached values, the logged-in session and the queued job survived composer update; the breaks came from copying the 13 skeleton's config files. An app that relies on the framework's fallback prefixes gets the new hyphenated ones from composer update itself.
Do I need to flush the cache after fixing serializable_classes?
No. The stored bytes don't change; the restriction applies when a value is read. After the missing classes were added, entries written on Laravel 12 read back as real objects again.
Why were users logged out after upgrading to Laravel 13?
Copying the 13 skeleton's config/session.php switches serialization to json and, for files older than laravel/laravel v12.3.0, renames the session cookie; either change alone logs users out. With Redis sessions, the new prefixes in config/cache.php and config/database.php (for files older than v12.1.0) do it too. Pin SESSION_COOKIE, CACHE_PREFIX and REDIS_PREFIX, and keep 'serialization' => 'php' for the upgrade.
Which classes go in serializable_classes for a paginated Eloquent query?
Illuminate\Pagination\LengthAwarePaginator (Illuminate\Pagination\Paginator for simplePaginate), Illuminate\Database\Eloquent\Collection and the model class. Add every eager-loaded related model, and Illuminate\Database\Eloquent\Relations\Pivot for a belongsToMany relation. PHP matches exact class names, so a parent class doesn't cover its children.

Share this post

Share on X
Share on LinkedIn
Share on Reddit
Share on Hacker News
Copy link

Stay in the Loop

Get the latest posts delivered to your inbox - on your schedule.

No spam. Unsubscribe anytime.

Scroll to top