Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
8.33% covered (danger)
8.33%
1 / 12
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResultCache
8.33% covered (danger)
8.33%
1 / 12
25.00% covered (danger)
25.00%
1 / 4
33.73
0.00% covered (danger)
0.00%
0 / 1
 remember
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 forgetDomain
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 rememberKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 forget
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace App\Services;
4
5use Illuminate\Cache\TaggableStore;
6use Illuminate\Support\Facades\Cache;
7
8/**
9 * FIRE-1145: Centralised read-through query cache with domain-scoped invalidation.
10 *
11 * Replaces the legacy pattern of `Cache::get/put` (with `base64_encode($sql)` as
12 * key) + `Cache::flush()` on every write — which destroyed the entire app cache
13 * on every mutation across 45 call sites, regardless of domain.
14 *
15 * Usage on the READ path (drop-in replacement for the old Cache::get/put block):
16 *
17 *     $result = ResultCache::remember('quotations', $query, 600, fn() => DB::select($query));
18 *
19 * Usage on the WRITE path (drop-in replacement for `Cache::flush()`):
20 *
21 *     ResultCache::forgetDomain(['quotations', 'users']);
22 *
23 * Domains in use (see findings/FIRE-1145_Investigation.md for the full map):
24 *   - 'quotations'                 — list_quotations + analytics endpoints
25 *   - 'ongoing_jobs'               — list_ongoing_jobs
26 *   - 'pipelines'                  — list_pipelines
27 *   - 'leave'                      — list_leave
28 *   - 'itv'                        — list_itv
29 *   - 'digital_campaign_analytics' — DigitalCampaignAnalytics endpoints
30 *   - 'users'                      — get_commercial_with_pendings, get_commercials, etc.
31 *
32 * Cross-domain dependencies (some mutations invalidate more than one domain — see
33 * the inventory in the findings file before adding new call sites).
34 *
35 * Raw `Cache::flush()` is BANNED — a CI grep gate in bitbucket-pipelines enforces this.
36 */
37final class ResultCache
38{
39    /**
40     * Read-through cache for query result sets, scoped to a domain tag.
41     *
42     * @param  string  $domain  Domain tag (one of the values listed in the class docblock).
43     * @param  string  $sql  The raw SQL string — used to derive the cache key.
44     * @param  int  $ttl  TTL in seconds. Existing call sites use 600 (10 minutes).
45     * @param  callable  $fn  Closure that runs the query on cache miss.
46     */
47    public static function remember(string $domain, string $sql, int $ttl, callable $fn): mixed
48    {
49        // Domain-scoped invalidation relies on cache tags, which only the
50        // redis/memcached/array stores support (prod = redis). On a store that
51        // can't tag (e.g. local CACHE_DRIVER=file) `Cache::tags()` throws
52        // "This cache store does not support tagging." Fall back to running the
53        // query uncached so the app still works locally instead of 500-ing.
54        if (! Cache::getStore() instanceof TaggableStore) {
55            return $fn();
56        }
57
58        return Cache::tags([$domain])->remember(
59            'sql:'.md5($sql),
60            $ttl,
61            $fn
62        );
63    }
64
65    /**
66     * Invalidate all cache entries tagged with the given domain(s).
67     * Pass a string for one domain, or an array for several at once.
68     */
69    public static function forgetDomain(string|array $domains): void
70    {
71        // Nothing to invalidate when the store can't tag (remember() didn't
72        // cache anything in that case).
73        if (! Cache::getStore() instanceof TaggableStore) {
74            return;
75        }
76
77        Cache::tags((array) $domains)->flush();
78    }
79
80    /**
81     * Read-through cache with an explicit (non-SQL) key. Use this when
82     * the cached value isn't a query result — e.g. `company_ids:{$userId}`
83     * built from an Eloquent `pluck()` call.
84     *
85     * @param  string   $domain Domain tag (one of the values listed in the class docblock).
86     * @param  string   $key    Explicit cache key (NOT hashed — caller picks).
87     * @param  int      $ttl    TTL in seconds.
88     * @param  callable $fn     Closure that builds the value on cache miss.
89     */
90    public static function rememberKey(string $domain, string $key, int $ttl, callable $fn): mixed
91    {
92        return Cache::tags([$domain])->remember($key, $ttl, $fn);
93    }
94
95    /**
96     * Invalidate a single explicit key inside a domain. Use when the
97     * mutation only affects one user / one row and a full domain flush
98     * would be wasteful.
99     */
100    public static function forget(string $domain, string $key): void
101    {
102        Cache::tags([$domain])->forget($key);
103    }
104}