From e35b8df86ac0d39dab3027c25e5d8dd96bcb174c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 7 May 2026 14:31:07 -0300 Subject: [PATCH] fix: cast cached post count to int and align local cache default to redis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/Models/Traits/HasUsage.php | 11 ++++++++++- config/cache.php | 2 +- tests/Feature/Models/HasUsageTraitTest.php | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/Models/Traits/HasUsage.php b/app/Models/Traits/HasUsage.php index e0c3a07e..e97f8f72 100644 --- a/app/Models/Traits/HasUsage.php +++ b/app/Models/Traits/HasUsage.php @@ -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 $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(), diff --git a/config/cache.php b/config/cache.php index fe802996..a5b1c67b 100644 --- a/config/cache.php +++ b/config/cache.php @@ -17,7 +17,7 @@ | */ - 'default' => env('CACHE_STORE', 'database'), + 'default' => env('CACHE_STORE', 'redis'), /* |-------------------------------------------------------------------------- diff --git a/tests/Feature/Models/HasUsageTraitTest.php b/tests/Feature/Models/HasUsageTraitTest.php index e19eea15..fdbbcadd 100644 --- a/tests/Feature/Models/HasUsageTraitTest.php +++ b/tests/Feature/Models/HasUsageTraitTest.php @@ -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(); +});