logo

The sneaky failure mode in Laravel 13's hardened cache

A cache hit can now hand you a broken object instead of your data

Joel Clermont
Joel Clermont
2026-08-04

In an earlier tip, I explained why unserialize() is on every PHP security checklist. PHP has had the allowed_classes protection for a decade, but in the last couple releases, Laravel has made it something we can leverage right from our configuration.

Let's look at an example using the cache feature:

// config/cache.php
'serializable_classes' => false,

Laravel 12 added some internal plumbing to support this configuration, but left old behavior by default to prevent introducing a breaking change. With the release of Laravel 13, the above option was now defaulted to a more strict value.

The new default of false means no objects at all come back from the cache. If this breaks your code, you could also change it back to the old behavior by setting it to true, allowing any class to be rebuilt from your cache. There's also a middle ground, where you can provide an allow-list of specific classes.

As a side note, sessions also got hardened in Laravel 13. Because session data is almost always scalars and arrays, a new config option defaults session serialization to JSON, and then unserialize() never runs at all.

The cache layer is different, though, because apps may want to store rich PHP objects there, requiring a more nuanced allow-list approach.

This improvement is great, but I need to call out one part that might surprise you during an upgrade. Even with this cache config locked down, nothing prevents you from storing an object in the cache, and it doesn't even throw an exception when reading it back out of the cache.

Take a look at this example:

$stats = Cache::remember('some-cache-key', 3600, function () {
    return json_decode(Http::get($url)->body());
});

json_decode() returns stdClass objects, and plenty of apps cache API responses exactly like this. With serializable_classes set to false, even stdClass is not allowed to be unserialized.

The same is true if you cache the results of an expensive Eloquent query. Models and collections are objects too, so they hit the same restriction.

So what happens? The write operation succeeds, and the read operation still reports a cache hit, but instead of retrieving your cached data, every object comes back as __PHP_Incomplete_Class.

Nothing will fail until some code touches a property on one of those broken objects. That might be a sort in a controller or a loop in a Blade view, far away from the cache call that caused it. The error message doesn't mention your cache either, so it can take a while to connect the dots.

So what should you do? You don't need to opt out of the new, safer behavior altogether. You have a few options to consider, and I'll compare them in an upcoming tip.

Here to help,

Joel

P.S. Upgrading a big app is exactly when sneaky issues like this slip through. A code review gives you a second set of eyes before your users find the problem for you.

Toss a coin in the jar if you found this helpful.
Want a tip like this in your inbox every weekday? Sign up below 👇🏼
email
No spam. Only real-world advice.