fix: cast cached post count to int and align local cache default to redis

Production crashed on every Inertia request after the PostHog branch
landed:

  TypeError: App\Models\Account::cachedPostCount(): Return value must
  be of type int, string returned at app/Models/Traits/HasUsage.php:80

Root cause: Laravel's RedisStore optimises is_numeric values by storing
them raw (not serialised) so they remain INCR/DECR-able atomically.
The side effect is that an int written via Cache::put comes back as a
string on read. The strict ': int' return type on cachedPostCount then
threw a TypeError.

Local dev and CI used the file/array/database drivers respectively,
which serialise everything blindly and preserve the int type, so the
bug never surfaced before deploy.

Fixes:
- Cast the Cache::remember result to (int) — defensive, survives any
  driver-specific behaviour. Documented inline so the cast is not
  later removed as redundant.
- Change config/cache.php default from 'database' to 'redis' so local
  dev matches prod by default and similar driver-specific bugs surface
  before merge instead of after deploy.
- Regression test that seeds the cache with a literal string (mimics
  the production Redis read) and asserts cachedPostCount still returns
  an int.
This commit is contained in:
Paulo Castellano 2026-05-07 14:31:07 -03:00
parent a829553a24
commit e35b8df86a
3 changed files with 30 additions and 2 deletions

View file

@ -66,6 +66,15 @@ public function featureLimits(): array
}
/**
* The `(int)` cast on the cached value is load-bearing: Laravel's
* RedisStore skips serialize()/unserialize() for is_numeric values so
* they can be incremented atomically the side effect is that an int
* stored via `Cache::put` comes back as a string on read. Without the
* cast, the strict `: int` return type throws a TypeError under the
* Redis cache driver (production). File/array/database drivers don't
* have this optimisation and preserve the type, which is why the bug
* never surfaced in tests or local dev.
*
* @param array<int, string> $workspaceIds
*/
private function cachedPostCount(array $workspaceIds): int
@ -74,7 +83,7 @@ private function cachedPostCount(array $workspaceIds): int
return 0;
}
return Cache::remember(
return (int) Cache::remember(
"account:{$this->id}:posts_count",
self::POST_COUNT_CACHE_TTL,
fn () => Post::whereIn('workspace_id', $workspaceIds)->count(),

View file

@ -17,7 +17,7 @@
|
*/
'default' => env('CACHE_STORE', 'database'),
'default' => env('CACHE_STORE', 'redis'),
/*
|--------------------------------------------------------------------------

View file

@ -104,3 +104,22 @@
// No cache entry should be written for the empty case.
expect(Cache::has("account:{$this->account->id}:posts_count"))->toBeFalse();
});
test('postCount survives a string-typed cache value (Redis serializer quirk)', function () {
// Laravel's RedisStore stores numeric values raw (not serialised) so they
// can be INCRemented atomically. The side effect: an int written via
// Cache::put comes back as a string on read. The test driver is `array`
// which preserves type, so we seed the cache with a literal string here
// to mimic what production sees and assert the return type stays int.
Workspace::factory()->create([
'account_id' => $this->account->id,
'user_id' => $this->owner->id,
]);
Cache::put("account:{$this->account->id}:posts_count", '42', 300);
$usage = $this->account->usage();
expect($usage['postCount'])->toBe(42);
expect($usage['postCount'])->toBeInt();
});