Daravelv1.13.0
ENID GitHub

Redis

On one server Daravel needs no Redis: the coordinator isolate keeps the cache, locks and rate limits for every worker isolate in memory, and the database queue is ACID. daravel_redis is for several servers, where that state has to be shared and the database becomes the bottleneck.

Your setup Use
One VPS or one container The defaults (CACHE_STORE=process, the database queue). Do not add Redis
Several servers, little traffic, PostgreSQL or MySQL already there CACHE_STORE=database, the database queue, REVERB_SCALING_ENABLED on the database. Still no Redis
Several servers with tight latency, strict rate limits, many jobs or WebSockets across servers, or a Laravel app on Redis already daravel_redis

It works with Redis 6.2 or newer and with Valkey, on one server, behind Sentinel or as a Redis Cluster, and speaks the protocol itself (no native library).

Installing#

dartisan redis:install --docker   # or: dart pub add daravel_redis

redis:install adds the package, .withRedis() and the redis:* commands, the .env keys in .env.example and, with --docker, a Valkey service in the compose file (--server=redis for Redis).

// bootstrap/app.dart
import 'package:daravel_redis/daravel_redis.dart';

Application createApp() => Application.configure(basePath: basePath)
    .withRedis()
    // ...
    .create();

withRedis() registers everything below; nothing connects until something uses Redis, so it costs nothing while .env does not ask for it.

Choosing it in .env#

REDIS_HOST=10.0.0.15
REDIS_PASSWORD=secret
# or REDIS_URL=rediss://user:secret@redis.example.com:6380/0

CACHE_STORE=redis          # cache, locks, the JWT denylist
CACHE_LIMITER=redis        # rate limits, one round trip each
QUEUE_CONNECTION=redis     # jobs picked up as soon as they are pushed

REVERB_SCALING_ENABLED=true
REVERB_SCALING_DRIVER=redis  # broadcasting servers share Echo's clients
Setting What Redis does
CACHE_STORE=redis Cache and Cache.lock on every server. Logout (Auth.jwtGuard().invalidate()) reaches every server too, since the JWT denylist lives in the default store
CACHE_STORE=tiered The same, with each isolate's memory in front of Redis (below)
CACHE_LIMITER=redis Each attempt is one Lua script; blocked attempts are not counted
QUEUE_CONNECTION=redis Jobs in Redis; an idle worker blocks on BLPOP and starts a pushed job at once instead of after --sleep. Unique-job locks and queue:restart / queue:pause go to Redis as well (QUEUE_UNIQUE_LOCK_STORE=redis)
REVERB_SCALING_DRIVER=redis Frames between servers over Pub/Sub; presence members, the channels API and cache channels in Redis (Broadcasting)

The cache uses the cache connection (REDIS_CACHE_DB, 1 by default), so dartisan cache:clear deletes only the cache's keys (SCAN and UNLINK on its prefix, never FLUSHDB) and never queued jobs.

Configuration#

Variable Default Purpose
REDIS_URL none redis:// or rediss:// (TLS), user:password@host:port/database; redis+sentinel:// and redis+cluster:// list several hosts (below). Replaces the variables below
REDIS_HOST, REDIS_PORT 127.0.0.1, 6379 The server
REDIS_USERNAME, REDIS_PASSWORD none ACL user (Redis 6+) and password
REDIS_DB, REDIS_CACHE_DB 0, 1 Databases of the default and cache connections, as in Laravel
REDIS_PREFIX <app>-database- Put before every key, Laravel 12's default
REDIS_CACHE_LOCK_CONNECTION default Where cache locks live, as Laravel's lock_connection
REDIS_TLS false TLS without rediss://; boot warns when a public host goes without it
REDIS_COMMAND_TIMEOUT, REDIS_CONNECT_TIMEOUT 1s, 2s A command that times out closes its connection, which reopens on the next one
REDIS_MAX_IN_FLIGHT 1024 Commands waiting for replies on one connection; more are refused at once
REDIS_MODE standalone sentinel or cluster, with REDIS_NODES
REDIS_NODES REDIS_HOST:REDIS_PORT host:port,host:port: the Sentinels, or the cluster nodes to start from
REDIS_SENTINEL_SERVICE mymaster The master's name at the Sentinels
REDIS_SENTINEL_USERNAME, REDIS_SENTINEL_PASSWORD none The Sentinels' own credentials
REDIS_SERIALIZER json php writes cached values as Laravel's serialize(); both are always read
REDIS_TIERED_MAX_KEYS, REDIS_TIERED_TTL 10000, 60s The tiered store's entries per isolate and their longest life
QUEUE_REDIS_ENGINE list stream keeps jobs in a stream with a consumer group
QUEUE_REDIS_NAMESPACE daravel The queues' key space, apart from Laravel's queues:

/up/ready gains a redis check (PING). Passwords never appear in logs or errors.

Sentinel and Cluster#

# The master Sentinel names, found again after a failover
REDIS_URL=redis+sentinel://:secret@10.0.0.21:26379,10.0.0.22:26379/mymaster/0

# A Redis Cluster, from any of its nodes
REDIS_URL=redis+cluster://:secret@10.0.0.31:6379,10.0.0.32:6379

With Sentinel, each connection asks the Sentinels for the master and checks its ROLE. After a failover the old master drops its clients, which reconnect to the new one; a write refused by a demoted master (READONLY) did not run and is sent again to the new master. Writes accepted by the old master in the moment before Sentinel demotes it can be lost: that window belongs to Redis's asynchronous replication.

In a cluster, each command goes to the master of its key's hash slot, from a slot map read with CLUSTER SLOTS and corrected by MOVED and ASK while slots move. MGET, DEL, UNLINK, EXISTS and TOUCH over keys of several slots are split per slot; SCAN (and so cache:clear) visits every master. Everything Daravel runs as one script shares a hash tag ({default} for a queue), so it stays on one slot; your own scripts over several keys need one too ({user:7}:a, {user:7}:b), or the server answers CROSSSLOT. A cluster has only database 0, so REDIS_CACHE_DB does not apply there.

A cache in each isolate#

CACHE_STORE=tiered keeps what each isolate reads in its own memory (L1) in front of Redis (L2), and Redis tells every server when one of those keys is written (CLIENT TRACKING in broadcast mode, RESP3). A read from L1 is then never older than the last write the server was told of; only the short trip of that notice separates them.

final user = await Cache.store('tiered').remember('user:7', null, loadUser);

Its values live under a prefix of their own (tiered:), so only they are broadcast; locks and rate limits go to the redis store as usual. Until tracking is running, and on servers without it, every read goes to Redis. An entry lives at most REDIS_TIERED_TTL and follows the key's own time to live; at most REDIS_TIERED_MAX_KEYS stay, the least recently read leaving first.

Cache tags#

await Cache.tags(['people', 'artists']).put('John', john, ttl: const Duration(hours: 1));
await Cache.tags(['people']).flush();

Tagged entries are laid out as Laravel 12 lays them out on Redis: the value under <cache prefix><sha1 of the tags>:<key>, and each tag's keys in a sorted set <cache prefix>tag:<name>:entries, scored by when they expire. A flush deletes the keys its tags list, so either framework flushes what the other tagged. Expired keys stay in the lists until dartisan cache:prune-stale-tags (Laravel's command of the same name, worth scheduling) drops them. The tiered store tags too, with tag ids as Laravel's array store.

Queue engines#

QUEUE_REDIS_ENGINE=list (the default) keeps jobs in lists and sorted sets. stream keeps them in a Redis stream read by a consumer group: the server itself tracks the jobs being run (XPENDING), and a job whose worker died is claimed by another after QUEUE_RETRY_AFTER (XAUTOCLAIM, by the server's clock). Both wake idle workers the same way, and the payload stays byte for byte what was pushed. Switch engines with an empty queue: each keeps its jobs under keys of its own.

Rate limit algorithms#

Laravel's fixed window lets up to twice the limit through around a reset. With CACHE_LIMITER=redis, two other algorithms are one script each, on the Redis server's clock:

limiter.define('login', (request) => Limit.perMinute(5).by(request.ip).gcra());
limiter.define('search', (request) => Limit.perMinute(60).slidingWindow());
Algorithm Counts
fixedWindow (default) Laravel's: hits since the window's first hit
slidingWindow() The current window plus the previous one, weighted by how much of it is still within the decay
gcra() One attempt every decay / limit, the whole limit as a burst after a quiet period

Retry-After says when the next attempt passes under each.

Events that resume#

router.get('/orders/events', (request) =>
    Response.sse(Redis.events('orders').since(request)));

await Redis.events('orders').publish({'id': 7}, event: 'created');

Each event goes into a stream (<REDIS_PREFIX>sse:orders, about the last 1000 kept) and its stream id is the event's id:. A browser that reconnects sends Last-Event-ID; it first gets what it missed, then the live events, none twice and none lost in between. Every listener of a channel in an isolate shares one XREAD BLOCK connection.

The Redis facade#

await Redis.set('greeting', 'halo', ttl: const Duration(minutes: 5));
final greeting = await Redis.get('greeting');
await Redis.command(['HSET', 'user:7', 'name', 'Ana']);
final [name, ttl] = await Redis.connection().pipeline([
  ['HGET', 'user:7', 'name'],
  ['TTL', 'user:7'],
]);

final script = RedisScript("return redis.call('INCRBY', KEYS[1], ARGV[1])");
await Redis.eval(script, keys: ['visits'], args: [1]);

await Redis.publish('orders', '{"id":7}');
Redis.subscribe(['orders']).messages.listen((message) { /* ... */ });

publish(..., sharded: true) and subscribe([...], sharded: true) use sharded Pub/Sub (Redis 7): in a cluster a message stays within its channel's shard, and a subscription follows the shard when its slot moves. Sharded channels must share a hash slot ({orders}:created, {orders}:paid).

// At most 10 per minute, counted with Laravel's Redis::throttle('reports')
await Redis.throttle('reports').allow(10).every(const Duration(minutes: 1))
    .then(() => sendReport(), (error) => releaseLater());

// At most 3 at a time (Laravel's Redis::funnel)
await Redis.funnel('imports').limit(3).block(const Duration(seconds: 5))
    .then(() => runImport());

throttle and funnel use Laravel's scripts and keys (with REDIS_PREFIX), so both frameworks count against one limit. As in Laravel, a throttle window runs from a whole second to that second plus the period. A funnel in a cluster needs a hash tag in its name ({imports}), since its slots are keys of their own.

Each isolate keeps one connection per server, and commands sent without awaiting each other share its round trips (pipelining). Blocking commands and subscriptions get connections of their own; a subscription subscribes again after the connection drops.

Commands#

// bootstrap/commands.dart
import 'package:daravel_redis/commands.dart';

final List<CommandFactory> commands = [...redisCommands];

They come from a library of their own, so the server does not compile the CLI.

Command What it does
redis:ping [--connection] The server, its version, the mode and the round trip
redis:stats [--connection] Each master's INFO (memory, clients, hits) and what the client did (connections, redirections, failovers)
redis:keys-audit [--connection] [--match] [--limit] Keys by type under REDIS_PREFIX, and the cache keys that never expire
redis:cli [--connection] [--write] [command ...] One command, or a prompt; only reading commands run without --write (KEYS neither: use SCAN)
redis:inspect <key> [--connection] What a key holds: type, time to live, encoding, size, memory and its first items (values hidden in production). Also the MCP tool redis_inspect (dartisan)
cache:prune-stale-tags [--store] Drops expired keys from the tag lists

Guarantees#

  • Cache reads fail open: with Redis down, a read is a miss and the application answers from the source. Writes throw.
  • Locks fail closed and belong to their owner, as Laravel's RedisLock. They are single-instance locks, not Redlock: a failover with asynchronous replication can lose one. For work that must never run twice (billing), lock in the database.
  • Jobs run at least once: a job reserved by a worker that died runs again after QUEUE_RETRY_AFTER. Handlers should be idempotent, as in Laravel. A job's payload stays exactly what was pushed.
  • Pub/Sub is at most once: an event published while a server reconnects does not reach that server's clients.

With Laravel on the same Redis#

Locks Shared once both use the same REDIS_PREFIX and CACHE_PREFIX: the same keys on the same connection, SET NX PX and the same release script. dartisan adopt gives Daravel a cache prefix of its own, so set CACHE_PREFIX to Laravel's to share them
Numbers in the cache Shared: stored as plain digits
Other cache values Shared with REDIS_SERIALIZER=php: Daravel writes PHP's serialize() for strings, booleans, lists and maps, and reads Laravel's values, objects as maps of their properties. With json, Laravel cannot read Daravel's values
Tags Shared: the same keys and tag lists; a flush from either reaches what the other tagged
Redis.throttle, Redis.funnel Shared with Redis::throttle and Redis::funnel of the same name: Laravel's scripts and keys
Queues Kept apart (daravel: namespace): Laravel's workers cannot run Daravel jobs, nor the other way round

bench/interop/redis.sh checks this against Laravel 12 with predis: a lock taken by either is refused to the other with the same owner, a cached number counts up from both sides, each reads the other's cached arrays and tagged entries and flushes the other's tags, and one throttle counts both.

Not yet#

REDIS_TOPOLOGY=coordinator, which would route every isolate's commands through one set of connections per server for managed services with a low connection limit, is planned. Until then each isolate opens its own.