In a new Laravel 13 app, why does an Eloquent collection cached with Cache::remember() come back as __PHP_Incomplete_Class objects on later requests?
answer
- only the cache hit is broken
- serializable_classes in config/cache.php
- unserialize with allowed_classes false
- array store in tests does not serialize
- cache arrays, or allow-list the classes
basics
~10 sThe Laravel 13 skeleton sets serializable_classes to false in config/cache.php, so stores unserialize with allowed_classes false and every object, including an Eloquent Collection and its models, becomes __PHP_Incomplete_Class on a cache hit.
solid answer
~40 sLaravel 13's skeleton `config/cache.php` sets `'serializable_classes' => false`. The database, file, Redis, DynamoDB and storage stores pass that to PHP's `unserialize($value, ['allowed_classes' => false])`, which turns any object into `__PHP_Incomplete_Class`. So the first request (a miss) gets the real collection straight from the closure, while every later request (a hit) gets unusable objects. Tests often miss it because the skeleton's test config uses the `array` store, which by default does not serialize at all. The setting exists to blunt deserialization gadget chains if `APP_KEY` leaks. Fix it by caching plain data (`->toArray()`, scalars) or by listing the exact classes in `serializable_classes`. Apps upgraded with their own older `config/cache.php` have no such key and keep unrestricted unserialization.
code
php · 12 lines<?php
use App\Models\Currency;
use Illuminate\Support\Facades\Cache;
// Breaks on the second request under serializable_classes = false:
$models = Cache::remember('fx:currencies', 3600,
fn () => Currency::active()->get());
// Survives any setting: plain arrays of scalars.
$names = Cache::remember('fx:currency-names', 3600,
fn () => Currency::active()->pluck('name', 'code')->all());go deeper
Recall that Laravel 13 refuses to rebuild PHP objects from cache by default, so cache arrays and scalars rather than models.
Explain why only cache hits break, which config key controls it, and why the array store in tests hides the problem.
Diagnose from the __PHP_Incomplete_Class_Name, choose between plain-data caching and a precise allow-list, and add handleUnserializableClassUsing reporting.
Set a team rule on what may be cached, weighing the gadget-chain hardening against the convenience of caching rich objects.
## The symptom A currency converter caches the active currencies as models: ```php $currencies = Cache::remember('fx:currencies', 3600, fn () => Currency::active()->get()); ``` On a fresh Laravel 13 app the first page load works. From the second load on, code such as `$currencies->pluck('code')` fails, and a dump shows `__PHP_Incomplete_Class` objects with an `__PHP_Incomplete_Class_Name` of `Illuminate\Database\Eloquent\Collection`. Clearing the cache "fixes" it for exactly one request. ## The cause: `serializable_classes` The Laravel 13 skeleton's `config/cache.php` ends with: ```php 'serializable_classes' => false, ``` Its comment explains the intent: no PHP classes are unserialized from cache storage, to prevent gadget-chain attacks if `APP_KEY` is leaked. The upgrade guide rates the change as **medium** impact. Mechanically: 1. On a **miss**, `remember()` returns the closure's result directly — a real `Eloquent\Collection` of `Currency` models — and stores its serialized form. 2. On a **hit**, the store calls `unserialize($value, ['allowed_classes' => false])`. PHP's unserializer replaces every object whose class is not allowed with a `__PHP_Incomplete_Class` placeholder. Arrays, strings, integers, floats and booleans come back normally. 3. The repository returns the placeholder. Laravel lets you observe this through `Repository::handleUnserializableClassUsing($callback)`, which receives the key and the class name each time an incomplete object is read. Which stores apply the restriction: | Store | Honours `serializable_classes`? | |---|---| | `database`, `file`, `redis`, `dynamodb`, `storage` | yes, via their own `unserialize()` | | `array` with `'serialize' => false` (the skeleton's setting) | no — values are kept as live PHP values | | `array` with `'serialize' => true` | yes | That table is why the bug hides in tests: `phpunit.xml` sets `CACHE_STORE=array`, and the array store keeps the real object in memory without serializing it, so assertions pass while production on the database store breaks. ## Fixes, in order of preference - **Cache plain data.** Store what the page needs, not the objects: `Currency::active()->get(['code', 'name'])->toArray()` or `->pluck('name', 'code')->all()`. Arrays of scalars round-trip under any setting and are smaller. - **Allow-list specific classes.** If you really need objects, list every class that appears in the payload — the collection class and each model class, plus any object nested inside, such as a date object: ```php 'serializable_classes' => [ Illuminate\Database\Eloquent\Collection::class, App\Models\Currency::class, ], ``` - **Report stragglers.** Register `Repository::handleUnserializableClassUsing()` in a service provider to log the key and class of any incomplete object, so a missed class is found quickly rather than as a fatal error in a view. - **Make tests realistic.** For a test that must catch this, use a serializing store — for example the `array` store with `serialize` set to `true`. ## Upgraded apps versus new ones The framework's own fallback `config/cache.php` has **no** `serializable_classes` key, and a missing key means unrestricted `unserialize()`. An app upgraded from Laravel 12 that keeps its existing `config/cache.php` therefore behaves as before. The trap hits new Laravel 13 apps, and upgraded apps that copy the new skeleton config without auditing what they cache. Setting the key back to `null` restores unrestricted unserialization, at the cost of the gadget-chain hardening. ## Diagnosing it quickly 1. Dump the cached value (`dd(Cache::get('fx:currencies'))`) and read the `__PHP_Incomplete_Class_Name` property — it names the first class that was refused. 2. Check `config('cache.serializable_classes')`: `false` means no classes, an array means only those, `null` or a missing key means unrestricted. 3. Confirm which store served the value; the default `array` store in tests never serializes, so it will not reproduce the failure. 4. Decide between converting the payload to arrays and extending the allow-list, then clear the affected keys so no incomplete entries linger until their TTL. The error usually surfaces far from the cache call — a Blade view calling a method on what it expects to be a model — so recognising the class name is the fastest route back to `config/cache.php`.
- In Laravel 13, why does a feature test using the default test config not catch cached models turning into __PHP_Incomplete_Class?The skeleton's `phpunit.xml` sets `CACHE_STORE=array`, and the array store is configured with `'serialize' => false`, so it keeps live PHP objects in memory and never unserializes them. The allowed-classes restriction only applies when a store unserializes, as the database, file and Redis stores do.
- In Laravel, what security problem is serializable_classes => false meant to reduce?Cached values are PHP-serialized. If an attacker can write crafted serialized data into the cache — the framework comment names a leaked `APP_KEY` as the scenario — unserializing it could instantiate arbitrary classes and trigger a gadget chain. Refusing to instantiate any class on read removes that path; allow-listing re-opens it only for classes you name.
saying these in an interview costs you the question
- Laravel 13 caches objects as JSON, so models lose their methods
- The array test store proves cached models survive the round-trip
- An upgraded app's existing config/cache.php also gets serializable_classes false
- Clearing the cache permanently fixes the incomplete-object error
- Allow-listing the model class alone is enough for a cached Eloquent collection