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

**Author:** Mozex | **Published:** 2026-09-25 | **Tags:** Laravel, PHP, DevOps | **URL:** https://mozex.dev/blog/27-laravel-12-to-13-the-config-sync-that-logs-users-out-and-breaks-the-cache

---


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

<!--more-->

Calling a method on that collection threw this:

```text
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](https://laravel.com/docs/upgrade) 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](https://github.com/laravel/framework/issues/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](https://github.com/laravel/framework/pull/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](https://mozex.dev/blog/25-shouldbeunique-vs-withoutoverlapping-neither-sets-a-lock-expiry-for-you#the-four-locks-on-one-page) 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:

```php
$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:

```php
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](https://github.com/mike-bronner/laravel-model-caching/issues/588) 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):

```php
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:

```bash
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](https://github.com/laravel/framework/pull/61652) proposed a fix and was closed unmerged on September 21, 2026.

Taylor Otwell explained the default in [PR #60346](https://github.com/laravel/framework/pull/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:

```bash
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:

```ini
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:

```php
'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.