hiblaphp / cache
Promise based cache implementation based on PSR16 cache interface
Requires
- php: ^8.3
- hiblaphp/event-loop: dev-main
- hiblaphp/promise: dev-main
Requires (Dev)
- laravel/pint: ^1.25
- pestphp/pest: ^4.0
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
This package is auto-updated.
Last update: 2026-03-20 14:58:11 UTC
README
Async-first cache primitives for the Hibla ecosystem.
A Promise-based cache abstraction built on top of
hiblaphp/promise. All operations
return a Promise rather than a raw value, making cache reads and writes
first-class participants in async workflows — composable with await(),
Promise::all(), and the rest of the Hibla promise API.
Ships with an in-memory ArrayCache implementation with LRU eviction and
nanosecond-precision TTL, and a CacheInterface for building or swapping in
other backends.
Contents
Getting started
ArrayCache
- Basic Usage
- TTL — Expiring Items
- Size Limit and LRU Eviction
- Bulk Operations
has()— Existence Check
Async integration
Reference
Meta
Installation
composer require hiblaphp/cache
Requirements:
- PHP 8.3+
hiblaphp/promise
Introduction
Most cache APIs in PHP return raw values directly. This works fine for
synchronous code, but as soon as you want to use a cache inside an async
workflow — alongside HTTP requests, database queries, or other in-flight
work — a synchronous return value breaks the composition model. You cannot
pass it to Promise::all(), you cannot await() it alongside other
operations, and you cannot swap to a remote cache backend later without
changing every call site.
hiblaphp/cache makes every cache operation return a Promise. For the
built-in ArrayCache all operations resolve immediately — there is no actual
async work happening under the hood. But the interface is identical to what a
Redis or Memcached backend would expose. This means your application code
composes cleanly with the rest of the Hibla async stack today, and swapping
to a network-backed cache later requires no changes at the call site.
Basic Usage
use Hibla\Cache\ArrayCache; use function Hibla\await; $cache = new ArrayCache(); // Store a value await($cache->set('user:1', $user)); // Retrieve a value — resolves with $default on cache miss $user = await($cache->get('user:1')); // Delete a value await($cache->delete('user:1')); // Clear all values await($cache->clear());
All methods return a Promise. In a synchronous context you can use
await() or ->wait() to retrieve the resolved value. Inside an async()
block use await() to suspend cooperatively:
async(function () use ($cache) { $user = await($cache->get('user:1')); if ($user === null) { $user = await(fetchUserFromDatabase(1)); await($cache->set('user:1', $user, ttl: 300)); } return $user; });
TTL — Expiring Items
Pass a TTL as the third argument to set() and setMultiple(). Items are
not actively garbage collected — expiry is checked lazily on the next read
(get(), getMultiple(), has()). Expired items are also prioritized for
eviction when the cache is full — see Eviction priority.
TTL uses hrtime() internally — a monotonic clock that is immune to system
clock adjustments. This means TTLs are accurate even if the system time
changes while the cache is running.
Integer and float TTL
Pass an int or float for a TTL in seconds. Floats allow sub-second
precision:
// Expire after 5 minutes await($cache->set('session:abc', $session, 300)); // Expire after 500 milliseconds await($cache->set('rate-limit:user:1', 1, 0.5)); // Expire after 1.5 seconds await($cache->set('lock:resource', true, 1.5));
DateInterval TTL
Pass a DateInterval when you want to express TTL in calendar terms:
// Expire after 1 hour await($cache->set('report:daily', $report, new DateInterval('PT1H'))); // Expire after 7 days await($cache->set('token:refresh', $token, new DateInterval('P7D'))); // Expire after 30 minutes await($cache->set('session:tmp', $data, new DateInterval('PT30M')));
Size Limit and LRU Eviction
Pass a $limit to the constructor to cap the number of items the cache will
hold. When the limit is reached and a new item is added, the cache evicts one
item to make room before storing the new one:
// Hold at most 1000 items $cache = new ArrayCache(limit: 1000);
Without a limit the cache grows without bound:
// Unlimited — default $cache = new ArrayCache();
The minimum valid limit is 1. Passing 0 or a negative value throws an
\InvalidArgumentException:
$cache = new ArrayCache(limit: 0); // throws InvalidArgumentException $cache = new ArrayCache(limit: -1); // throws InvalidArgumentException
Eviction priority
When the cache is over the limit, the eviction strategy prefers expired items over live ones:
- Expired item available — the item with the earliest expiry timestamp is evicted first, regardless of when it was last accessed. Clearing out a stale item is always preferable to evicting a live one.
- No expired items — the least recently used item is evicted. Read
operations (
get(),getMultiple()) update LRU order on each access.has()intentionally does not — seehas().
$cache = new ArrayCache(limit: 3); await($cache->set('a', 1)); await($cache->set('b', 2, ttl: 0.001)); // expires almost immediately await($cache->set('c', 3)); usleep(2000); // let 'b' expire // Adding a fourth item — 'b' is evicted first because it is expired, // even though 'a' is the least recently used live item await($cache->set('d', 4)); $b = await($cache->get('b')); // null — evicted $a = await($cache->get('a')); // 1 — still present
Bulk Operations
getMultiple()
Fetch multiple keys in a single call. Returns an associative array of
key => value pairs. Keys not found in the cache resolve to $default:
$results = await($cache->getMultiple(['user:1', 'user:2', 'user:3'])); // $results = [ // 'user:1' => $user1, // found // 'user:2' => null, // not found — default // 'user:3' => $user3, // found // ]
Pass a custom default for missing keys:
$results = await($cache->getMultiple(['config:a', 'config:b'], default: []));
setMultiple()
Store multiple key-value pairs in a single call. All entries share the same TTL:
await($cache->setMultiple([ 'user:1' => $user1, 'user:2' => $user2, 'user:3' => $user3, ], ttl: 300));
deleteMultiple()
Delete multiple keys in a single call:
await($cache->deleteMultiple(['user:1', 'user:2', 'user:3']));
has() — Existence Check
has() checks whether a key exists in the cache and has not expired. It
returns false for keys that are present but have passed their TTL:
$exists = await($cache->has('user:1')); // true or false
Note:
has()intentionally does not update LRU order. It is a lightweight existence check with no side effects — onlyget()andgetMultiple()promote an item to most-recently-used when the value is actually retrieved.
This means checking whether a key exists before fetching it does not disturb
the eviction order. If you check for a key and then immediately get it, only
the get() updates the LRU position:
if (await($cache->has('user:1'))) { // LRU order unchanged $user = await($cache->get('user:1')); // LRU order updated here }
Using with await()
await() is provided by hiblaphp/async
— the async/await layer of the Hibla stack. If you have not used it before,
it is a plain PHP function that suspends the current Fiber until a promise
settles, or falls back to blocking synchronously when called outside a Fiber.
See the hiblaphp/async documentation for
a full introduction.
All ArrayCache methods resolve immediately — there is no network round trip.
await() returns the value on the same tick without suspending the Fiber:
use function Hibla\async; use function Hibla\await; async(function () use ($cache, $userId) { // Cache-aside pattern — check cache, fall back to source $user = await($cache->get("user:$userId")); if ($user === null) { $user = await(Http::get("/users/$userId")); await($cache->set("user:$userId", $user, ttl: 60)); } return $user; });
Using with Promise Combinators
Because every operation returns a Promise, cache calls compose naturally
with Promise::all() and other combinators. Warm multiple cache entries or
read multiple keys concurrently alongside other async work:
use Hibla\Promise\Promise; use function Hibla\await; // Warm multiple cache entries concurrently await(Promise::all([ $cache->set('user:1', $user1, 300), $cache->set('user:2', $user2, 300), $cache->set('user:3', $user3, 300), ])); // Read multiple keys concurrently alongside other async work [$cached, $liveConfig] = await(Promise::all([ $cache->get('user:1'), Http::get('/config'), ]));
No Cancellation Support
ArrayCache does not support cancellation and none of its returned promises
have onCancel() handlers registered. This is intentional, not an oversight.
Cancellation is only meaningful when there is real async work in flight —
an HTTP request that can be aborted, a timer that can be cancelled, a stream
read that can be stopped. A CancellationToken or promise->cancel() call
is useful precisely because it can reach into that in-flight work and stop it
before it completes.
ArrayCache operations are entirely synchronous. Every method completes its
work — the array read, the LRU update, the expiry check — and calls
Promise::resolved() before returning. The promise is already fulfilled by
the time the caller receives it. There is nothing in flight to cancel:
$promise = $cache->get('user:1'); // By this line, the array has already been read and the promise is // already fulfilled. Calling cancel() on it is a no-op — the work // is done. $promise->cancel(); // no-op — isFulfilled() is already true
This is the same reason Promise::resolved() and Promise::rejected() do
not support cancellation — once a promise is already settled, its result is
final and cancellation has nothing to act on.
If you are building a cache backend that performs real async I/O — a Redis
client, a Memcached adapter, or any network-backed implementation of
CacheInterface — you should register onCancel() handlers on the deferred
promises your methods return, wiring cancellation to the underlying connection
or request abort mechanism:
public function get(string $key, mixed $default = null): PromiseInterface { $promise = new Promise(); $requestId = $this->redis->get($key, function (?string $value) use ($promise, $default) { $promise->resolve($value !== null ? unserialize($value) : $default); }); // Real async work — cancellation can actually stop something $promise->onCancel(function () use ($requestId) { $this->redis->cancel($requestId); }); return $promise; }
For ArrayCache specifically, there is simply nothing to wire cancellation
to — the work is always already done.
CacheInterface
CacheInterface is the contract all cache implementations in the Hibla
ecosystem implement. All methods return a PromiseInterface — this is the
only requirement that distinguishes it from a PSR-16 synchronous cache.
Type-annotate against the interface rather than a concrete class so you can swap implementations without changing call sites:
use Hibla\Cache\Interfaces\CacheInterface; class UserRepository { public function __construct(private CacheInterface $cache) {} public function find(int $id): PromiseInterface { return async(function () use ($id) { $cached = await($this->cache->get("user:$id")); if ($cached !== null) { return $cached; } $user = await($this->db->query("SELECT * FROM users WHERE id = ?", $id)); await($this->cache->set("user:$id", $user, ttl: 300)); return $user; }); } } // Use ArrayCache in development, swap to RedisCache in production — // UserRepository does not change $repo = new UserRepository(new ArrayCache(limit: 500));
Implementing your own backend
Implement CacheInterface to add a new backend. All methods must return a
PromiseInterface — use Promise::resolved() for synchronous results and a
deferred Promise for genuinely async operations. Register onCancel()
handlers on any deferred promises that wrap real in-flight work:
use Hibla\Cache\Interfaces\CacheInterface; use Hibla\Promise\Interfaces\PromiseInterface; use Hibla\Promise\Promise; class RedisCache implements CacheInterface { public function get(string $key, mixed $default = null): PromiseInterface { $promise = new Promise(); $requestId = $this->redis->get($key, function (?string $value) use ($promise, $default) { $promise->resolve($value !== null ? unserialize($value) : $default); }); $promise->onCancel(function () use ($requestId) { $this->redis->cancel($requestId); }); return $promise; } // ... implement remaining methods }
API Reference
ArrayCache
| Method | Description |
|---|---|
__construct(?int $limit = null) |
Create a cache. Pass a limit to cap the number of items. Null for unlimited. Throws \InvalidArgumentException if limit is less than 1. |
get(string $key, mixed $default = null): PromiseInterface |
Resolve with the cached value, or $default on miss. Checks expiry lazily. Updates LRU order on hit. |
set(string $key, mixed $value, mixed $ttl = null): PromiseInterface |
Store a value. TTL accepts int, float, or DateInterval. Resolves with true. Evicts if over limit. |
delete(string $key): PromiseInterface |
Remove a key. Resolves with true. |
clear(): PromiseInterface |
Remove all keys. Resolves with true. |
getMultiple(iterable $keys, mixed $default = null): PromiseInterface |
Fetch multiple keys. Resolves with array<string, mixed>. Missing keys resolve to $default. Updates LRU order on each hit. |
setMultiple(iterable $values, mixed $ttl = null): PromiseInterface |
Store multiple key-value pairs with a shared TTL. Resolves with true. |
deleteMultiple(iterable $keys): PromiseInterface |
Delete multiple keys. Resolves with true. |
has(string $key): PromiseInterface |
Resolve with true if the key exists and has not expired. Does NOT update LRU order. |
TTL types
| Type | Example | Behavior |
|---|---|---|
null |
set('k', $v) |
No expiry — item lives until evicted or deleted |
int |
set('k', $v, 300) |
Expires after N seconds |
float |
set('k', $v, 0.5) |
Expires after N seconds, sub-second precision |
DateInterval |
set('k', $v, new DateInterval('PT1H')) |
Expires after the interval |
Development
git clone https://github.com/hiblaphp/cache.git
cd cache
composer install
./vendor/bin/pest
./vendor/bin/phpstan analyse
License
MIT License. See LICENSE for more information.