rajmundtoth0 / hybrid-cache
A Laravel-native hybrid cache package with local and distributed layers plus stale-while-revalidate semantics.
Requires
- php: ^8.3
- illuminate/cache: ^13.0
- illuminate/http: ^13.0
- illuminate/support: ^13.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- larastan/larastan: ^3.0
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/phpstan: ^2.1
- rajmundtoth0/phpstan-forbidden: ^1.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 07:56:18 UTC
README
Hybrid Cache is a Laravel 13 package for application-level caching with a simple default path:
- pick a TTL
- cache the value locally and remotely
- let the value expire naturally
- do not require remote invalidation machinery to get started
If that is all you need, the package stays small and predictable. When you need more, it also supports a local cache layer for fast reads on the current node, a distributed cache layer for shared state across nodes, and stale-while-revalidate semantics for controlled refreshes under load.
The intent is to make the common case easy first: use TTL-based caching without building a custom invalidation system, then opt into coordinated refresh and operational tooling only when your workload actually needs it.
Why this package exists
Most applications do not need remote invalidation as their first step. They need a cache key, a TTL, and behavior that stays understandable under load.
Laravel gives you strong cache primitives, but once an application wants to combine a local cache, a shared cache, and safe refresh behavior, teams usually end up assembling the same pieces themselves:
- a fast local layer
- a shared distributed layer
- stale reads during refresh windows
- refresh coordination to avoid stampedes
This package wraps that behavior in a single Laravel-native abstraction with minimal configuration and a deliberately small public API.
Installation
Requires PHP 8.3+ and Laravel 13.
composer require rajmundtoth0/hybrid-cache
Publish the config if you want to override the defaults:
php artisan vendor:publish --tag=hybrid-cache-config
Configuration
The default config is intentionally small:
return [ 'local_store' => env('HYBRID_CACHE_LOCAL_STORE', 'apc'), 'distributed_store' => env('HYBRID_CACHE_DISTRIBUTED_STORE', env('CACHE_STORE', 'file')), 'stale_ttl' => (int) env('HYBRID_CACHE_STALE_TTL', 300), 'lock_ttl' => (int) env('HYBRID_CACHE_LOCK_TTL', 30), 'key_prefix' => env('HYBRID_CACHE_PREFIX', 'hybrid-cache:'), 'refresh' => [ 'default_ttl' => (int) env('HYBRID_CACHE_REFRESH_TTL', 60), 'http' => [ 'enabled' => false, 'path' => '/hybrid-cache/refresh', 'middleware' => ['signed', 'throttle:60,1'], ], 'keys' => [], 'prefixes' => [], 'groups' => [], ], ];
Laravel 13's default cache.serializable_classes setting disables object unserialization. The package stores its envelopes as arrays, but if your cached values contain objects, explicitly allow those value classes in your application's config/cache.php, or convert the values to arrays before caching.
If you want APCu as the local layer, your application needs a Laravel cache store named apc and the PHP APCu extension installed. APCu project: https://github.com/krakjoe/apcu
'stores' => [ // ... 'apc' => [ 'driver' => 'apc', ], 'hybrid' => [ 'driver' => 'hybrid', 'local_store' => env('HYBRID_CACHE_LOCAL_STORE', 'apc'), 'distributed_store' => env('HYBRID_CACHE_DISTRIBUTED_STORE', 'redis'), 'stale_ttl' => (int) env('HYBRID_CACHE_STALE_TTL', 300), 'lock_ttl' => (int) env('HYBRID_CACHE_LOCK_TTL', 30), 'key_prefix' => env('HYBRID_CACHE_PREFIX', 'hybrid-cache:'), ], ],
To use the package through Laravel's cache manager, add a store entry to your application's config/cache.php:
'stores' => [ // ... 'hybrid' => [ 'driver' => 'hybrid', 'local_store' => env('HYBRID_CACHE_LOCAL_STORE', 'apc'), 'distributed_store' => env('HYBRID_CACHE_DISTRIBUTED_STORE', 'redis'), 'stale_ttl' => (int) env('HYBRID_CACHE_STALE_TTL', 300), 'lock_ttl' => (int) env('HYBRID_CACHE_LOCK_TTL', 30), 'key_prefix' => env('HYBRID_CACHE_PREFIX', 'hybrid-cache:'), ], ],
Then you can either resolve the store explicitly:
Cache::store('hybrid')->get('users:index');
or make it your default cache store:
CACHE_STORE=hybrid
Recommended production setup:
- local store:
apcbacked by APCu for low-latency in-process reads - distributed store:
redis(or Valkey),memcached, ordatabase, depending on your existing Laravel cache setup; counters (increment,decrement, Laravel's rate limiter) needredisordatabase - stale window: keep it short and deliberate so stale responses are bounded and understandable
If you want the simplest rollout, start with a TTL and let expiration do the invalidation work. You can add coordinated refresh later without changing the basic read API.
If both configured stores are the same, the package still works, but it behaves as a single-store SWR cache instead of a true hybrid cache.
Usage
Start simple
If you just want TTL-based caching without remote invalidation, start here:
use rajmundtoth0\HybridCache\Facades\HybridCache; $users = HybridCache::flexible( key: 'users:index', ttl: 300, callback: fn () => User::query()->latest()->take(50)->get(), );
That gives you a small, production-friendly path:
- set a TTL
- cache locally for fast reads
- share state through the distributed store
- let expiration drive refresh instead of wiring custom invalidation flows
For many applications, that is enough. You can stop there and keep the model simple.
Add a stale window when needed
use rajmundtoth0\HybridCache\Facades\HybridCache; $value = HybridCache::flexible( key: 'dashboard:stats', ttl: 300, staleTtl: 30, callback: fn () => $statsService->snapshot(), );
Through Laravel's cache facade
use Illuminate\Support\Facades\Cache; $value = Cache::store('hybrid')->flexible( 'dashboard:stats', [300, 330], fn () => $statsService->snapshot(), );
The flexible call on the hybrid store follows Laravel 13's native signature:
- the first TTL value is the fresh window
- the second TTL value is the total serveable lifetime, including stale time
[300, 330]means 5 minutes fresh and up to 30 additional seconds stale
Behavior summary once you opt into stale serving:
- fresh values are returned immediately from the local layer when available
- local misses fall back to the distributed layer and rehydrate the local layer
- the active pointer for coordinated refresh lives only in the local cache (APCu) for keys explicitly marked
coordinated => true - stale values are returned during the stale window while a refresh is coordinated behind a lock
- hard-expired values trigger a refresh before a new value is stored
Forget a key
HybridCache::forget('dashboard:stats');
forget removes the key from the distributed store and from this node's local
store. A refresh of the key that is already running keeps its lock, so no
second builder starts, but it can no longer store the value it built. Other
nodes keep their local copy until it expires or a sync replaces it.
Update a key's expiration
Cache::store('hybrid')->touch('dashboard:stats', 300);
touch keeps the current distributed value and resets its fresh lifetime to the supplied TTL in both cache layers, without an additional stale window. It returns false for missing or expired distributed entries. A zero or negative TTL removes the key.
Counters
Cache::store('hybrid')->increment('api:hits'); Cache::store('hybrid')->decrement('stock:42', 3); Cache::store('hybrid')->add('login:attempts', 0, 60); // atomic; seeds a counter
Counters live in the distributed store only. Every node reads the same value, and concurrent increments are never lost:
- Each change uses the distributed store's atomic increment. On Redis and Valkey that is a single Lua script.
- A missing key starts from zero and lives for
stale_ttlseconds, or 60 seconds whenstale_ttlis 0. The counter and its lifetime are created in the same atomic step. - An existing counter keeps its remaining lifetime, so a rate limiter's window is not extended.
- An integer stored with
put()orforever()is converted into a counter on its first increment. Other values makeincrementreturnfalseand stay untouched. add()is atomic in the distributed store. Laravel's rate limiter uses it together withincrement().
Counters need the redis (Valkey), database or array distributed store;
other stores throw UnsupportedStoreException (see
Supported backends).
A node keeps the local copy that its own put() wrote until it expires, or
until that node increments or forgets the key. Create counters with
increment() or add() so every node reads the shared value.
Optional coordinated refresh (Programmatic / HTTP / CLI)
You do not need this section to get value from the package. The default path is still: cache with a TTL and let values expire naturally.
If you want more control, the package can optionally expose a programmatic service API, a signed POST endpoint, and an Artisan command to trigger refreshes on a node. The HTTP and CLI entry points are disabled by default and are intended for trusted/internal use cases such as deploy hooks, admin-triggered updates, and orchestration.
Key properties:
- disabled by default
- POST only
- signed URLs required
- rate limited
- ordinary refresh definitions write directly to the base key
- add
coordinated => trueonly for keys or prefixes that need the local slot/pointer promotion flow - coordinated promotion flow stays safe (lock → write distributed payload → update local slot → flip local pointer)
Local pointers live in the local cache only. If you need to reset them, use the HTTP/CLI refresh or clear the local cache; the distributed store is never queried for the active pointer.
Enable the endpoint and define refreshers in config/hybrid-cache.php:
'refresh' => [ 'http' => [ 'enabled' => true, ], 'keys' => [ 'dashboard:stats' => [ 'handler' => [\App\Cache\DashboardStats::class, 'build'], 'ttl' => 300, 'stale_ttl' => 60, 'group' => 'dashboard', 'coordinated' => true, ], ], 'groups' => [ 'dashboard' => [ 'keys' => ['dashboard:stats'], ], ], ],
Without coordinated => true, refreshes stay on the fast base-key TTL/SWR path and do not create local :active or :slot:* entries.
Trigger programmatically:
use rajmundtoth0\HybridCache\Services\HybridCacheRefresherService; $result = app(HybridCacheRefresherService::class)->refresh( key: 'dashboard:stats', ); $groupResult = app(HybridCacheRefresherService::class)->refresh( group: 'dashboard', refreshKeys: true, );
refresh() is the general application-facing entry point. It expects exactly one of key, prefix, or group. If you already know the specific target type, you can also call refreshKey(), refreshPrefix(), or refreshGroup() directly.
Trigger via HTTP:
use Illuminate\Support\Facades\URL; $url = URL::signedRoute('hybrid-cache.refresh'); // POST JSON: { "key": "dashboard:stats" }
Trigger via CLI:
php artisan hybrid-cache:refresh dashboard:stats php artisan hybrid-cache:refresh --group=dashboard --all
Opt-in multi-server warm synchronization
Available since 0.3.0 (see CHANGELOG).
The standard signed /hybrid-cache/refresh endpoint refreshes one receiving
node. Its distributed rebuild lock prevents duplicate builders, but it does
not populate the APCu memory of every web server automatically.
For a cluster, a separate publisher can build the distributed snapshot once, then ask each PHP-FPM node to copy that published snapshot into its own local APCu. No node runs the builder again:
writer / queue worker → publish (shared Valkey payload)
→ signed POST /hybrid-cache/sync on node A → local APCu
→ signed POST /hybrid-cache/sync on node B → local APCu
→ signed POST /hybrid-cache/sync on node C → local APCu
Configure each node's direct HTTPS origin, not an application load balancer, and enable the signed HTTP interface on every participating instance:
// config/hybrid-cache.php 'refresh' => [ 'http' => [ 'enabled' => true, 'nodes' => [ 'web-a' => 'https://web-a.internal', 'web-b' => 'https://web-b.internal', ], ], 'keys' => [ 'dashboard:stats' => [ 'handler' => [\App\Cache\DashboardStats::class, 'build'], 'ttl' => 300, 'stale_ttl' => 60, 'coordinated' => true, ], ], ],
All nodes must share the same signing key (APP_KEY), trusted route
configuration, cache prefix and distributed store. Internal node URLs must
route directly to the serving FPM pool: a separate PHP CLI process does
not share its APCu. HTTP origins are rejected unless explicitly allowed with
refresh.http.allow_insecure_http = true; this opt-in is only for trusted
internal development networks. Keep the signed endpoints inaccessible from
untrusted networks.
The sync route always requires signed:relative. Its limiter is set by
refresh.http.sync_middleware (default ['signed:relative', 'throttle:60,1'];
throttle:60,1 is added when no throttle is listed). Laravel keys that
throttle by client IP, and nodes using a shared limiter cache store share one
budget for the publisher: one key fanned out to two nodes uses two requests.
Size the limit for your peak fan-out rate, otherwise excess notifications are
rejected with HTTP 429 and reported as failed nodes.
Publish and synchronize one registered key:
php artisan hybrid-cache:refresh dashboard:stats --cluster
Or invoke app(\rajmundtoth0\HybridCache\Services\HybridCacheClusterService::class)->refreshAndSyncKey('dashboard:stats')
from trusted application code. To retry nodes after a successful publish,
call syncNodes('dashboard:stats') without rebuilding the value, or
syncNodes('dashboard:stats', ['web-b']) to notify only the nodes that failed.
A publish usually follows a change to the source data, so it never skips
because another builder holds the refresh lock: that build may have read the
data before the change. It waits up to refresh.publish_wait_seconds
(default 30) and then builds; if the lock is still held, nothing is published
and the result is failed with reason lock_timeout (--cluster exits
non-zero).
Besides the per-node status in data.nodes, the result lists the nodes that
did not acknowledge in data.failed_nodes. Pass that list back to
syncNodes() to retry them. data.details reports each node's attempts, last
HTTP status, duration, acknowledgement time and the node's own failure reason:
superseded: a newer payload was published, so this digest is not retried.changed_during_synclocal_overwritten: another worker replaced the copy before the node could verify it.no_fresh_payloadlocal_write_failedunreachablehttp_<status>
The caller sends a relative signed URL with the requested key and digest. Each node reads the authoritative fresh distributed envelope. It writes the inactive local slot for coordinated keys and promotes it. It acknowledges only after reading the copy back and confirming that the distributed store still publishes the same digest.
- A duplicate notification safely hydrates the same payload again.
- A delayed notification for a superseded digest is rejected without writing.
- A node that already holds a newer, still fresh copy keeps it.
- If a newer payload is published while a node writes an older one, the node evicts its copy instead of acknowledging it. The older payload stays visible only until that sync's post-write check, and the next read rehydrates from the distributed store.
The dispatcher retries each node a bounded number of times and returns
per-node acknowledgement and failure details. A partial failure is reported as
a failure, not as cluster success. Retrying is idempotent: syncNodes()
reads the current digest on every call, so a retry delivers the payload that
is published now, never an older one.
Consistency limits: delivery is synchronous and best-effort. It is not a durable queue or an atomic cluster-wide cutover.
- A process crash, a disconnected node or a freshly started node can leave local copies behind, and nodes keep serving their cached values while propagation is in progress.
- Publications carry versions, and local writes keep a newer, still fresh copy. But the local check and the write are separate steps, because APCu has no compare-and-swap for arrays. A read that hydrates an older payload in the microseconds after a sync verified its copy can still replace it. That copy stays until the next sync for the key or until it expires.
- A refresh that outlived its lock is fenced off by its lease (see Behavior guarantees).
Recovery is manual: re-run syncNodes() for data.failed_nodes, or run your
own durable retry or reconciliation job, and keep TTLs bounded. Strict
cross-node freshness would need a shared-version check on every read, or an
acknowledgement barrier, which adds read-path or coordination overhead. This
mechanism does not make local caching strongly consistent.
Optional group versions
You can use group versions to implement lazy group refresh without wildcard deletion:
$version = HybridCache::groupVersion('dashboard'); $key = "dashboard:stats:v{$version}";
Then trigger a group refresh to bump the version (and optionally refresh a subset of hot keys). An unset version reads as 1, and the first bump returns 2:
php artisan hybrid-cache:refresh --group=dashboard
API design
The public API is intentionally small:
HybridCache::flexible(...)HybridCache::forget(...)Cache::store('hybrid')->flexible(...)- standard cache operations through
Cache::store('hybrid')
That keeps the package easy to reason about and leaves room to grow later without committing to a wide surface area too early.
Architecture overview
The package stores a small envelope instead of a raw value:
value: the cached payloadfresh_until: the timestamp until the payload is considered freshstale_until: the timestamp until the payload may still be served as stale
Read path:
- Check the local store.
- If needed, check the distributed store.
- If a distributed hit is found, hydrate the local store.
- If the value is stale but still serveable, return it and coordinate a refresh.
- If the value is missing or expired, refresh and persist a new envelope.
Optional refresh coordination:
- the distributed store owns the refresh lock, through its native lock driver, and only the lock's owner can release it
- each lock holder gets a lease token. A refresh publishes only while its token
is current, so neither a lock that expired mid-build nor a
forget()lets an older build land - distributed stores without native locks are rejected rather than emulated
with an
add-style key, which could release another worker's lock
This keeps the first version small while still covering the critical production case of stale serving plus refresh coordination.
Behavior guarantees
These are the invariants the package is designed to uphold. They are verified by the test suite. The concurrency guarantees are also verified against a real Valkey server with concurrent processes (see Testing and quality tools).
Guaranteed
The distributed store is the shared source of truth. All nodes read from and write to the same distributed store. Local state is a read-through cache; it caches distributed results for the current node only and is never authoritative.
Distributed reads do not depend on pointer state.
The distributed store is always queried by base key. Pointer keys (:active, :slot:*) live in the local store only and are never consulted during a distributed read. A corrupt or missing local pointer never prevents a distributed lookup.
Pointer metadata is opt-in per refresh definition.
Keys use the fast base-key path by default. Pointer keys (:active, :slot:*) are only read for definitions marked coordinated => true.
A corrupt coordinated pointer never breaks a read.
If a coordinated key's local pointer holds an invalid value, it is cleared and the read falls back to the base key. If the base key also has no payload, the read returns null without throwing.
Counters do not lose concurrent updates.
increment(), decrement() and add() use the distributed store's atomic operations, and every node reads the same counter. A counter is created together with its lifetime in one atomic step, so none is left without an expiry. An existing counter keeps its lifetime.
A lock is released only by its owner.
Refresh locks use the distributed store's native lock driver. A worker whose lock expired cannot release the lock that another worker has acquired since. forget() never deletes a lock.
A refresh does not keep an invalidated value or overwrite a newer publication.
Every refresh lock carries a lease token. forget() revokes outstanding leases, and the next lock holder replaces the token. A refresh publishes only while its token is current. A build that started before forget(), or that outlived its lock, is returned to its caller but not kept in the distributed store. It is skipped when the lease is already lost, and evicted when the lease is lost while it is being written. In that case it is visible for at most that one round trip.
Stale values do not overwrite fresher state. Stale refreshes are lock-protected and fenced by their lease. A deferred stale refresh that finds a payload another worker already refreshed serves that payload instead of rebuilding it.
A node does not replace a newer local copy with an older payload. Every publication of a key under the refresh lock carries a higher version than the payload it replaces. Read-path hydration and node sync keep a local copy that is still fresh and newer than the incoming payload. Coordinated hydration writes the slot that is active at write time, so it never reverts a promotion.
A sync is acknowledged only for the payload the node serves.
syncLocal() acknowledges after reading its local copy back and confirming that the distributed store still publishes the same payload. A partial fan-out is reported as a failure that lists the failed nodes.
Coordinated refresh always writes to the inactive slot.
coordinatedRefresh() writes to the slot that is not currently named by the active pointer, then flips the pointer atomically. Readers see the old slot until the flip, then see the new envelope — there is no window where the active slot contains a partially-written payload.
Hydration preserves the envelope's original timestamps.
When the local store is hydrated from distributed data, the fresh_until and stale_until timestamps are copied as-is. A stale distributed envelope is hydrated as stale; a fresh envelope is hydrated as fresh. Hydration never extends or shortens the serveable window.
Single-store mode is explicitly supported.
When local_store and distributed_store name the same cache driver, the package operates as a single-store stale-while-revalidate cache. Local-only mechanics (active pointers, slot writes) are automatically bypassed. No pointer-specific behavior is required for correctness. The store must still provide native locks (see Supported backends).
Eventual consistency
- Local copies are not invalidated across nodes.
forget(),put()and counter changes update the distributed store and the local copy of the node that made them. Other nodes serve their local copy until it expires or a sync replaces it. - A node keeps the local copy that its own
put()orforever()wrote, even after another node increments the key. The copy stays until it expires, or until that node increments or forgets the key. Create counters withincrement()oradd(). put(),forever()andtouch()do not revoke refresh leases. A refresh that started before them can still publish after them, so the last writer wins.
Known limitations
- No atomic conditional write. The generic cache API cannot make the lease check and the distributed write one atomic step. A holder that stalls between its pre-write check and its write can expose an outdated payload for about one round trip, until its post-write check evicts it from the distributed store. A reader on another node that fetches it in that round trip can cache it locally until it expires. A Redis-only compare-and-set script could close this window; it is not included.
- Local writes are check-then-write. APCu offers no compare-and-swap for arrays, so the version check and the local write are separate steps. Within that microsecond window, another worker on the same node can still install an older payload over a newer one.
syncLocal()reports this aslocal_overwrittenwhen it sees it. A read-path hydration that races just after an acknowledged sync is not detected, and lasts until the copy expires or the next sync. - Unlocked writes are ordered by clock. Version order is exact for publications serialized by the refresh lock.
put(),forever(),add()and the unlocked fallback build are stamped with the writer's clock. Under clock skew, a node can skip caching a newer value locally while its own newer-looking copy is fresh. Reads still return the distributed value, andsyncLocal()resolves the conflict in favour of the distributed store. - Counter conversion races
put(). The first increment afterput()converts the integer under a lock. That is serialized with other increments, but not with aput()ortouch()of the same key at the same moment, so one of those writes can be lost. - Database counters at the expiry instant. The database store's increment ignores row expiry, so each increment reads the row first, which deletes an expired row. An increment that lands exactly as the row expires, between that read and the update, still adds to the old value once.
- Lock release depends on the driver. Release is atomic for the Redis/Valkey and database lock drivers. The file, Memcached and DynamoDB drivers check the owner and delete in two steps.
- Locks without expiry. A refresh lock taken with
lock: ['seconds' => 0]never expires, andforget()no longer deletes locks. If its holder crashes, release it withCache::store('hybrid')->lock('the-key')->forceRelease(). - Shared lock namespace and reserved keys. Refresh locks share the
lock:namespace withCache::store('hybrid')->lock(): a lock nameduserscontends with the refresh lock of the keyusers. A refresh is fenced only when its lock holder has a lease: while a plain lock of the same name is held, a miss serves its own build without storing it. Cache keys that start withlock:,group:,refresh-epoch:orcounter-lock:share storage with the package's own entries, so avoid them.
Supported backends
| Distributed store | get / put / forget |
flexible, refresh and publish (need locks) |
increment / decrement |
|---|---|---|---|
redis (Valkey) |
yes | yes, release by Lua compare-and-delete | yes, one Lua script; not with a phpredis serializer or compression |
database |
yes | yes | yes |
array (single process) |
yes | yes | yes |
memcached |
yes | yes | no: counters are unsigned and stop at zero |
dynamodb |
yes | yes | no: not verified |
file |
yes | yes | no: the increment is a read-modify-write |
apc, custom stores without LockProvider |
yes | no | no |
"No" means the operation throws
rajmundtoth0\HybridCache\Exceptions\UnsupportedStoreException instead of
running without the guarantee. The null store returns false from
increment.
Testing and quality tools
- Tests: Pest
- Mutation testing: Pest
--mutate - CI: GitHub Actions runs tests on PHP 8.3, 8.4, and 8.5, plus a dedicated Xdebug coverage job
- Static analysis: PHPStan at max level via Larastan
- Static policy checks:
rajmundtoth0/phpstan-forbiddento ban debugging and output constructs in package source - Formatting: PHP CS Fixer
Available commands:
composer test
composer test-coverage
composer mutate
composer mutate-bail
composer analyse
composer format
composer quality
The Valkey integration tests (tests/Integration) run when
HYBRID_CACHE_TEST_VALKEY_HOST is set. They are skipped otherwise. They need
the phpredis and pcntl extensions and flush the selected database (_DB,
default 15):
docker run -d --rm --name hybrid-cache-valkey -p 127.0.0.1:6391:6379 valkey/valkey:9.1.2-alpine HYBRID_CACHE_TEST_VALKEY_HOST=127.0.0.1 HYBRID_CACHE_TEST_VALKEY_PORT=6391 \ php -d apc.enable_cli=1 vendor/bin/pest
Set HYBRID_CACHE_TEST_VALKEY_CLIENT=predis to run them through Predis
(requires predis/predis).
Coverage and mutation testing use Xdebug:
XDEBUG_MODE=coverage composer test-coverage XDEBUG_MODE=coverage composer mutate
The Clover report is written to build/coverage/clover.xml.
Mutation testing currently runs Pest in --everything --covered-only mode because the suite does not yet annotate tests with covers() or mutates(). Adding those annotations later will make mutation runs narrower and faster.
There is also a small Makefile for the demo workflow:
make coverage make benchmark-build make benchmark-run-with make benchmark-run-without make benchmark-hit make benchmark-stop
Docker comparison setup
The repository includes two minimal Docker demos for side-by-side comparison:
docker/with-package/Dockerfile: Laravel app with this package enabled, usingapcas the local layer anddatabaseas the distributed layerdocker/without-package/Dockerfile: plain Laravel app using the standard database cache store andCache::remember
The goal is not to create a lab-grade benchmark. The goal is to make the difference in behavior easy to inspect quickly.
For a more repeatable benchmark, the repository also includes a Redis-backed benchmark harness:
docker-compose.benchmark.yml: starts Redis plus both demo appsscripts/run-benchmark.sh: builds the stack, waits for readiness, runs cold and warm request series, and prints summary statistics
This harness compares:
- baseline: Laravel
Cache::rememberon Redis - package: APC local cache plus Redis distributed cache through the hybrid store
Build the demo images
docker build -f docker/with-package/Dockerfile -t hybrid-cache-with-package . docker build -f docker/without-package/Dockerfile -t hybrid-cache-without-package .
Or use:
make benchmark-build
For the proper benchmark harness, use:
make benchmark-proper
If you want to run the benchmark stack manually:
docker compose -f docker-compose.benchmark.yml up -d curl "http://127.0.0.1:8081/benchmark?key=demo&ttl=30&stale=60&work_ms=40" curl "http://127.0.0.1:8082/benchmark?key=demo&ttl=30&work_ms=40" docker compose -f docker-compose.benchmark.yml down --remove-orphans
Benchmark results
Measured on the included Redis-backed benchmark harness with:
work_ms=40cold_runs=12warm_runs=40- each payload size averaged across
3benchmark runs on the current branch
Results:
| Payload | Approx size | With package, cold | With package, warm | Without package, cold | Without package, warm |
|---|---|---|---|---|---|
| Tiny metadata payload | 104B |
45.80ms |
0.28ms |
44.23ms |
1.26ms |
100KB |
102,514B |
45.73ms |
0.28ms |
44.68ms |
1.40ms |
2MB |
2,097,266B |
53.83ms |
1.01ms |
48.94ms |
5.72ms |
Interpretation:
- cold misses stay in the same general band because both variants still pay the compute cost and a distributed write on the first hit
- warm hits are materially faster with the package because the local APCu layer avoids the Redis round trip on repeated reads
- the warm-hit advantage remains clear as payload size grows: at
2MB, the package warm path averaged about1.01msversus5.72mswithout the package - stale serving continues to work as intended across payload sizes: the stale hit returns quickly with the same token, and the later request returns with a new token after refresh completes
Run them side by side
docker run --rm -p 8081:8000 hybrid-cache-with-package docker run --rm -p 8082:8000 hybrid-cache-without-package
Or use:
make benchmark-run-with make benchmark-run-without
Try the endpoints
curl "http://127.0.0.1:8081/benchmark?ttl=2&stale=5&work_ms=120" curl "http://127.0.0.1:8082/benchmark?ttl=2&work_ms=120"
Or use:
make benchmark-hit
What is being compared:
- with package: local read-through cache plus distributed cache plus stale serving during refresh windows
- without package: a standard single-store Laravel cache path using
Cache::remember
The package demo is expected to show lower cost on repeated local hits and smoother behavior when a value moves from fresh to stale.
Comparison
| Option | Good at | Less good at | Positioning relative to this package |
|---|---|---|---|
Laravel Cache::remember and direct cache store usage |
Simple caching with full framework control | Leaves two-tier orchestration, stale envelopes, and refresh coordination to application code | This package adds a focused, reusable hybrid cache pattern on top of Laravel's primitives |
| Response cache packages | Caching whole HTTP responses | Not aimed at general application data or service-layer caching | This package targets arbitrary values, not full response caching |
| Query cache packages | Caching Eloquent or query-builder output | Narrower scope and usually query-centric semantics | This package is general-purpose and not tied to ORM queries |
| Single-store SWR helpers | Simple stale-while-revalidate behavior | Usually no explicit local+distributed layering | This package centers on a hybrid layout first and then layers SWR on top |
This package does not try to replace Laravel's cache system. It provides one specific pattern on top of it: a small, composable abstraction for hybrid caching with bounded stale reads.
Tradeoffs
- The v1 API is intentionally narrow. That keeps the package easy to adopt, but it means advanced features like tags, per-key policies, and metrics hooks are not included yet.
- Deferred refresh is optimized for standard HTTP Laravel applications. In console execution, the package refreshes synchronously when serving stale values because there is no request termination hook to rely on.
- The default local store is
apc, which is a good fit for low-latency local reads, but it requires the APCu extension and anapcLaravel cache store to be configured. APCu project: https://github.com/krakjoe/apcu
Future extension points
- per-call policy objects instead of only scalar TTL arguments
- instrumentation hooks for cache hits, stale serves, and refresh timings
- optional support for tagged cache invalidation where underlying stores allow it
- integration helpers for queues if a team wants refresh work to move out of the request lifecycle
Package structure
config/
hybrid-cache.php
docker/
with-package/
Dockerfile
routes.web.php
without-package/
Dockerfile
routes.web.php
src/
Facades/
HybridCache.php
CacheEnvelope.php
HybridCacheManager.php
HybridCacheRepository.php
HybridCacheServiceProvider.php
HybridCacheStore.php
tests/
Feature/
HybridCacheTest.php
Pest.php
TestCase.php
.editorconfig
.gitignore
.php-cs-fixer.dist.php
composer.json
phpstan.neon.dist
phpunit.xml.dist
README.md