hyperf/model-cache
Hyperf model-cache adds automatic caching for Hyperf ORM models, reducing repeated database queries and speeding up reads. It integrates with model events for cache invalidation and supports configurable TTL and cache stores.
The compatibility reality check comes first. hyperf/model-cache is not a Laravel package. It belongs to the Hyperf 3.x framework (Swoole/Swow coroutines) and depends on hyperf/database, hyperf/di, hyperf/event, hyperf/cache and hyperf/redis. You cannot composer require it into a Laravel app. For a Laravel developer there are two legitimate ways to "use" it day-to-day:
genealabs/laravel-model-caching, which copies the same core contract: a Cacheable trait on the model, explicit findFromCache-style reads, and event-listener invalidation. Everything you learn here transfers.Minimal setup (Hyperf service):
composer require hyperf/model-cache — pulls hyperf/cache and hyperf/redis.php bin/hyperf.php vendor:publish hyperf/model-cache → config/autoload/model_cache.php.use Hyperf\ModelCache\Cacheable;
use Hyperf\ModelCache\CacheableInterface;
class User extends Model implements CacheableInterface
{
use Cacheable;
}
User::findFromCache($id) / User::findFromCacheMany($ids). There is no magic interception of find().MODEL_CACHE_ENABLE=true), hit the endpoint twice, verify with redis-cli --scan --pattern 'mc:*'.Where to look first. config/autoload/model_cache.php: default.cache_key (the sprintf key shape), prefix, ttl (~24h), empty_model_ttl (~10 min), load_script, use_redis_cluster, redis, and the per-model model map. Then the Cacheable trait, then the Handler layer (RedisHandler, ArrayHandler) — the only two seams you'll realistically extend.
First use case for a Laravel dev. Pick the shape you already know you'd cache by hand: a hot single-row read — user profile, product detail, feature-flag row — read hundreds of times per write. Keyed lookups, batched lookups, automatic invalidation on save()/delete(). That is the entire optimisation surface; nothing here helps with paginate(), aggregates, or query-shape caching.
On v3.2.3. The release note is a bare changelog pointer for v3.2.0...v3.2.3 — patch-level, no stated breaking changes or deprecations. Pin ^3.2, let the diff run in CI, nothing in your day-to-day changes.
1. Read-through at the call site, wrapped in a repository. The trait does not hijack your query builder, which is a feature: the decision to cache is greppable and reversible. In Laravel terms this is the same discipline as not reaching for Cache::remember inside a controller.
public function getProfile(int $id): ?User
{
return User::findFromCache($id);
}
2. Invalidation rides on model events — and only model events. A listener hooks Saved/Deleted and calls deleteCache(). So $user->save(), $user->delete() and Model::destroy($ids) all behave. Everything not routed through a hydrated model does not (see Gotchas). If you're the Laravel developer reviewing a Hyperf PR, this is the single line to check: is every write path event-emitting?
3. Batch reads for index pages. findFromCacheMany($ids) does one mget for hits, one whereIn for misses, then back-fills. Replace loops of findFromCache in list endpoints — a per-row round trip inside a coroutine is the classic first-week performance bug.
4. Negative caching is on by default. Misses are stored as null for empty_model_ttl (~10 min) to blunt id-scanning. Set it to 0 only where "row created a second ago must be visible"; keep it non-zero on public endpoints where enumeration is a real threat.
5. Per-model, per-service tuning. The model map overrides TTL and key shape per class. In a multi-service estate sharing one Redis, give each service (and each app environment) its own prefix. Services share the instance, not the invalidation contract — one service's flush must not evict another's keys.
6. Transaction ordering is a real decision. save() inside DB::transaction() fires the event before commit, so a concurrent reader can back-fill pre-commit state until the next write. Either invalidate after commit (DB::afterCommit / queued job) or accept the window explicitly. Laravel's afterCommit semantics map straight onto this.
7. Swap the handler in tests. Bind ArrayHandler in the test container so unit tests never touch Redis; keep RedisHandler in staging so you actually exercise the Lua paths. Same idea as Cache::fake() in Laravel — cheap tests by default, one real-backend suite.
8. Add a warm-up command. After a deploy or a prefix flush, chunk over ids and call findFromCacheMany. Turns a cold-cache stampede into a predictable backfill. Pair with a short TTL and jitter on write.
9. Don't try to make it do more than rows. paginate(), count(), aggregates and "top N" lists are not cached here — use hyperf/cache with short TTLs and version-stamped keys for those, exactly as you'd use Cache::remember in Laravel.
10. Version the key namespace on structural model changes. Adding casts, hidden fields or relations changes the serialized payload. Bump a segment in cache_key (e.g. mc:v2:%s:m:%s:%s:%s) and let the old keys expire naturally instead of flushing prod.
CacheableInterface classes exist. Hyperf\Cache\CacheableInterface (general cache component) and Hyperf\ModelCache\CacheableInterface. The wrong import gives you "method findFromCache does not exist" or a fatal trait/interface conflict. When nothing works, check the use block before anything else — this is the single most common first-hour mistake.Model::query()->where(...)->update(), builder increment()/decrement(), upsert(), and raw DB::update() fire no model events, so nothing invalidates. Route mutations through hydrated models, or call Model::destroyModelCache($ids) beside the raw write. This is where your debugging budget goes.findFromCache() hydrates from cached attributes and does not run global scopes. If a delete or restore path skipped the event, a soft-deleted row keeps being served. Audit every delete/restore path and invalidate explicitly.MODEL_CACHE_ENABLE=false degrades findFromCache to find(). If the "cache bug" disappears when flipped, the read path is fine and the problem is invalidation. Fastest triage you have.load_script. The Lua check-and-set script needs correct routing; a mismatched use_redis_cluster produces CROSSSLOT/NOSCRIPT noise under load. Keep load_script on for atomicity, but verify the cluster flag against real topology.prefix, is a self-inflicted outage. Namespace app + env + service into prefix.cache_key is sprintf-style over prefix/table/column/id. UUID string keys work; composite primary keys are not supported — only the single key column feeds mget.runtime/container/proxy. After editing the trait or an overriding method, rm -rf runtime/container or restart before concluding "the trait doesn't work" — the Laravel analogue is Octane's worker/opcache staleness, and it burns the same amount of time.TTL/GET the specific mc:* key → decide whether the key is wrong or invalidation never fired → inspect the write path for a bypass → check empty_model_ttl before assuming a broken cache when you get null for a row that now exists.HandlerInterface (Redis / Array) and the per-model config map are the two clean seams. Build custom keying, metrics, or a versioned invalidation scheme there rather than forking the trait — forking means re-forking every patch release.How can I help you explore Laravel packages today?