Never cache an Eloquent model
Caching a hydrated Site model made every second request 500 with __PHP_Incomplete_Class; caching a flat readonly value object fixed it and made the ingest path queueable.
The first request to POST /api/v1/collect worked. The second one 500'd. Same key, same origin, same payload, five seconds apart. The stack trace said something like:
Error: Call to a member function pluck() on __PHP_Incomplete_Class
That's the tell. __PHP_Incomplete_Class is what PHP hands you when unserialize() reads a class name it can't resolve at that moment, so it parks the properties in a placeholder object and lets you find out later, at the call site, in a place with no useful context. The first request was a cache miss and hit the database, so it worked. The second was a cache hit, and what came back out of the cache store was not the thing I put in.
What I had put in was an Eloquent model.
The version that looked reasonable
Isotherm's ingest endpoint runs on every beacon from every visitor of every tracked page. Before it writes anything it has to answer two questions: is this browser allowed to report for this site, and should this URL be recorded at all? Both need the site's allowed domains and its URL rules, and the answers also depend on is_active and snapshot_mode, which live on sites. So the read is SiteKey::with(['site.domains', 'site.urlRules']) — four tables (site_keys, sites, site_domains, url_rules) on the hot path of the highest-traffic route in the app. Obviously you cache it.
The first cut was the shape everyone writes:
return Cache::remember($key, now()->addMinutes(5), function () use ($publicKey) {
return SiteKey::with(['site.domains', 'site.urlRules'])
->where('public_key', $publicKey)
->whereNull('revoked_at')
->first()?->site;
});
Eager-load the relations, cache the model, done. Eloquent models are Serializable-ish, Laravel serializes them into queue payloads all the time, and this app's store is database (CACHE_STORE=database, in .env and as the default in config/cache.php), which means serialize()/unserialize() under the hood. It looks like it should work. It works exactly once.
What you are actually storing
A hydrated Site isn't a bag of columns. It's an object carrying $attributes, $original, $changes, $casts, a $connection name, and $relations — and $relations here holds two Illuminate\Database\Eloquent\Collection instances full of more models, each with their own copies of all of that. Serialize it and you're writing the object graph of the ORM into your cache store, then asking the store to give the graph back with every class still resolvable and every property still meaning what it meant.
The failure modes are not one bug, they're a family:
- The
databasestore round-trips throughserialize(), so anything that can breakunserialize()— a class not autoloadable at that instant, a queue worker booted from a slightly different container, an opcache state — gives you__PHP_Incomplete_Classinstead of an exception at the point of the mistake. - Deploy a change to
Site(add a cast, rename a relation) and every entry serialized against the old class definition is still sitting in the cache. It deserializes into an object whose shape no longer matches the code reading it. - Switching stores doesn't buy you out of this. Laravel's
RedisStoreruns its ownserialize()over every non-numeric value on the way in, so "just use Redis" gets you the same PHP-serialized object graph, now with a network hop and a worker between you and the stack trace. - The model carries a
$connection. You're caching a database handle's name and hoping the far end resolves the same one.
The reason this feels like a Laravel quirk rather than a design error is that Laravel does put models in queue payloads — and gets away with it because SerializesModels doesn't serialize the model. It serializes the class name and the primary key, and re-queries on the other side. That's the framework telling you, in code, that a model is not a portable value. I read that as a special case for jobs. It's the general rule.
The fix is not a workaround
The obvious patches are all worse than the disease.
You could cache the model and re-hydrate it on read. But then you're doing the query anyway, or writing bespoke __sleep/__wakeup on an Eloquent model, which is a maintenance liability aimed at the exact class most likely to change.
You could cache the raw rows from the four tables and stitch them together at the call site, which works, but it puts the domain-matching logic wherever the caller happens to live and there is now no single place that knows what "this site's configuration" means.
What the ingest path actually needs is not a Site. It needs seven fields of JSON-safe data — four scalars and three lists of strings. So that's what gets cached:
final readonly class SiteConfig
{
public function __construct(
public int $id,
public bool $isActive,
public array $domains,
public array $includes,
public array $excludes,
public int $keyId,
public string $snapshotMode,
) {}
}
Site::configFor() resolves the key, projects the model down to that, and caches the array, never the object:
public static function configFor(string $publicKey): ?SiteConfig
{
$cached = Cache::remember(self::cacheKey($publicKey), now()->addMinutes(5), function () use ($publicKey) {
$key = SiteKey::with(['site.domains', 'site.urlRules'])
->where('public_key', $publicKey)
->whereNull('revoked_at')
->first();
// Cache the miss too, so a flood of requests carrying a bogus key cannot become
// a flood of database lookups.
if (! $key || ! $key->site) {
return false;
}
return SiteConfig::fromSite($key->site, $key->id)->toArray();
});
return $cached === false ? null : SiteConfig::fromArray($cached);
}
toArray() on the way in, fromArray() on the way out. What sits in the store is ['id' => 3, 'isActive' => true, 'domains' => ['*.acme.com'], 'includes' => [], 'excludes' => ['/admin/billing/*'], 'keyId' => 12, 'snapshotMode' => 'full']. A few hundred bytes of scalars and string lists. It survives database, redis, memcached, file, array, and any serializer any of them are configured with, because there's nothing in it to break. The eager-load is still there — both authorization checks walk domains and rules, and an N+1 there would be most of the cost of the request — but it happens once per five minutes per key, not per beacon.
The false sentinel is deliberate. Cache::remember treats null as a miss and would re-run the closure every time, so a bot spraying a bogus key would turn a cache into a database amplifier. false is a cacheable "no such site".
The part I didn't expect
Flattening the model made the authorization checks pure functions of data, and they moved into SiteConfig:
public function tracksPath(string $path): bool
{
if ($this->excludes !== [] && Str::is($this->excludes, $path)) {
return false;
}
if ($this->includes === []) {
return true;
}
return Str::is($this->includes, $path);
}
No database, no $this->relations, no lazy load waiting to fire on a model that came from a cache. You can construct a SiteConfig in a test from seven literal arguments and assert that exclude /admin/billing/* beats include /admin/*, which is the rule the README promises and the one a customer will actually be angry about if it's wrong.
The other thing it bought was the queue. docs/SCALING-INGEST.md describes moving ingest off the synchronous write path — today it writes inline, which is fine to low thousands of events per minute and not fine after that. The job needs to know which site the batch belongs to. If the thing it needs is a Site, the payload either drags the model through SerializesModels (re-query on every job, on a worker, at ingest volume) or drags a serialized model through the queue and you're back at __PHP_Incomplete_Class. A SiteConfig is seven fields of plain data. It goes into a job payload as JSON and comes back out identical. The design that fixed the cache bug is the same design that makes the queue work, and I did not see that coming when I wrote it.
When this is the wrong call
Projecting a model into a value object costs you freshness, and freshness has to be managed by hand. Isotherm keeps a five-minute TTL and an explicit invalidation, and the invalidation is uglier than you'd like:
public function flushConfigCache(): void
{
foreach ($this->keys()->get() as $key) {
Cache::forget(self::cacheKey($key->public_key));
}
}
Every key gets flushed, not just the one that changed, because the cache is keyed by whatever key the browser sent — 'site:cfg:'.hash('sha256', $publicKey) — so editing a site's domains has to invalidate the entry under each of its keys. Today every mutation path calls it: rotateKey(), revoke, restore, snapshot-mode changes, and the four domain/rule endpoints in SiteController. That's nine call sites, and correctness depends on there being nine. Add a tenth that forgets, and a revoked snippet keeps recording for another five minutes with nothing failing loudly. That's a real bug surface I traded for, knowingly, and it's the kind that gets introduced six months later by someone who has never read this post. A cached model would have had the same problem, so it isn't an argument for the model, but it's the price of caching at all and you should say the price out loud.
And don't do this everywhere. In the dashboard, Site is used the way models are meant to be used: read fresh, mutate, save, one request. Building a value object for a page that renders an account's handful of sites once would be ceremony. The projection earns its keep only where the read is hot enough to cache, and the moment you decide to cache, the model stops being a thing you're allowed to store.
-
The query that reported 10,863 clicks instead of 213
One dashboard query left-joined two has-many tables and aggregated; every SUM came out multiplied by the session count, and nothing looked broken.
-
requestAnimationFrame does not fire in a hidden tab, and my code waited forever
An `await requestAnimationFrame` inside the snapshot settle pass hung forever in background tabs, holding the server's single-visitor election lock and blocking every other visitor.
-
Scroll depth has to be idempotent, and mine was not
Incrementing a bucket per scroll event counted one visitor several times in the shallow bands; the fix is a per-session high-water mark with the curve derived at read time.