directorytree/activeredis
ActiveRedis provides a simple, efficient, Eloquent-like active record API for interacting with Redis hashes in Laravel. Define models and create, update, delete, expire, and query records, with support for casts, events, timestamps, chunking, and searching.
ActiveRedis is a single-purpose package: it lets you treat Redis hashes as Eloquent-ish models. There is no service provider config to publish, no migrations, and no schema — everything is driven by class properties on your model.
Minimal setup
composer require directorytree/activeredis (PHP 8.1+, Laravel 9+, Redis 3+).config/database.php (e.g. database 10) so your models don't share a keyspace with cache, sessions and queues.DirectoryTree\ActiveRedis\Model, set protected ?string $connection = 'activeredis';, and declare $searchable and $casts before you write a single record.namespace App\Redis;
use DirectoryTree\ActiveRedis\Model;
class Visit extends Model
{
protected ?string $connection = 'activeredis';
protected array $searchable = ['ip'];
protected array $casts = [
'user_id' => 'integer',
'authenticated' => 'boolean',
'payload' => 'array',
];
}
Where to look first
visits:id:{uuid} and the getHashKey() / getBaseHash() / getHashPrefix() helpers, which is exactly what you need when inspecting data in redis-cli.query()) once you outgrow the documented find / chunk / each surface, since missing methods are forwarded dynamically.First real use case
Request/visit logging with automatic expiry is the sweet spot:
$visit = Visit::create([
'ip' => request()->ip(),
'url' => request()->url(),
'user_agent' => request()->userAgent(),
]);
$visit->setExpiry(now()->addHours(24));
The record self-destructs via Redis TTL, you get UUID keys for free, and you never touch MySQL for high-volume ephemeral writes.
TTL-first ephemeral records. Treat setExpiry() as the default rather than the exception. Rate-limit traces, one-time tokens, short-lived carts, "who viewed this page" logs, and transient job state all fit: create, set expiry, read by ID, let Redis expire it. getExpiry() returns a Carbon or null, which is handy for displaying "expires in…" in an admin view.
Casts as your schema. Since Redis stores strings, $casts is the only type information in the system. Wrap structured data in a single array/json/collection attribute instead of fanning out into many flat fields — it keeps the hash small and avoids searchable/index churn. Enum casts work well for status fields, and stored values are the backed enum values, so they remain greppable in redis-cli.
Custom keys for natural identifiers. Override protected string $key = 'email'; when you genuinely want O(1) lookup by an external identity (Visit::find('[email protected]')). Reserve this for values you control — you inherit responsibility for case sensitivity, reserved characters, and collision handling.
Lifecycle hooks over scattered logic. Push cross-cutting concerns into booted() or an observer: normalising the IP, stamping a tenant prefix into searchable fields, vetoing writes by returning false from creating/updating/deleting, or logging deletions. This is the closest thing the package has to "model policies", so use it instead of duplicating guards at call sites.
Chunk/each for batch jobs. There is no orderBy, so any "process all records" task should use each() or chunk() and take advantage of returning false from the callback to bail out early. This is the right shape for nightly cleanups, re-indexing after adding a cast, or fanning out to a queue.
Connection and prefixing as multi-tenancy. Give each domain its own model class and its own connection/database where practical. Since queries are SCAN {cursor} MATCH visits:id:*, isolating key spaces directly reduces scan cost — the README explicitly calls this out as the reason for a separate connection.
Bulk deletion by ID. Visit::destroy([...]) returns the count deleted, which makes it convenient for "delete these 40 IDs" admin actions. For "delete everything matching X", lean on TTLs or iterate with each() and call delete() per model.
Testing. The README has a Testing section — read it before writing your suite. In practice, point the model's connection at a throwaway Redis database, flush it in setUp, and assert against getHashKey() rather than internal key strings. Anything relying on SCAN should be tested with more than one record to avoid false confidence.
$casts, $model->user_id is "1", not 1, and getAttributes() always returns raw strings. Comparisons like === 1 will silently fail.Visit → visits). Rename the class and every existing record becomes unreachable by the query builder unless you migrate the keys manually.Visit::find('ABC') will not find visits:id:abc. Normalise IDs on write if users can supply them.: or * in keys or searchable values. Both are reserved by the key layout and the SCAN MATCH pattern — a stray colon silently breaks queries.$searchable is immutable schema. Adding, removing, or renaming a searchable attribute while records exist means those records can't be found again (the index keys won't match the model data). Decide the list up front; if you must change it, plan a full reindex by iterating every record with each() and re-saving.force: true deletes, it doesn't merge. create([...], force: true) and save(force: true) remove the existing hash and write fresh — old TTL, old fields, and old timestamps are gone. It's a concurrency escape hatch, not an upsert.SCAN is O(N) over the keyspace. COUNT is a hint, not a guarantee, so chunk(100, ...) may hand you 3 or 300 models. Never assume chunk size, and never assume ordering — there is none.setExpiry() sets a TTL on the model's hash. Verify whether the secondary keys created for searchable attributes expire with it; if they don't, long-TTL-less index entries accumulate and search results can point at missing hashes. Test this explicitly with a short TTL before relying on expiry in production.Visit::get() works because missing methods forward to the query builder — which also means mistyped static calls may fail in less obvious ways than a PHP error would suggest.redis-cli. Use SCAN 0 MATCH visits:id:* COUNT 100, HGETALL <hashkey>, and TTL <hashkey> to confirm what the ORM actually wrote. When a search returns nothing, inspect whether the index key exists at all before blaming your query.getNewKey() (custom ID generation), the $key property (identifier field name), $connection, $casts, $searchable, $timestamps, CREATED_AT/UPDATED_AT, and model events/observers. Almost every customisation request maps to one of these — reach for them before subclassing the query builder.How can I help you explore Laravel packages today?