Since the Redis licence change, "should we move to Valkey?" has sat in every Laravel team's backlog behind a wall of licensing think-pieces and a single docker run valkey line. Nobody tells you whether Horizon works, whether phpredis cares, or what happens to logged-in sessions halfway through the cutover.
This is the Laravel Valkey runbook I use. Ten steps, roughly ninety minutes of actual work, and a rollback that is one environment variable.
The short version of why it works: Valkey is a Linux Foundation fork of Redis at 7.2.4, and it is compatible at protocol, command, configuration and persistence-format level. Laravel's redis cache, queue, session and broadcast drivers talk RESP through phpredis or Predis. Neither client, nor Laravel, nor your application code knows the difference. phpredis explicitly lists Valkey as supported and Predis describes itself as a "Redis/Valkey client for PHP".
Current Valkey at the time of writing is 9.1.1, with 8.1.x and 7.2.x maintenance lines still receiving releases.
Audit what your app actually stores in Redis#
Before you move anything, find out what is in there. Laravel scatters far more into Redis than most teams remember, and the disposable keys and the load-bearing keys sit side by side in the same keyspace.
# Total keys per database
redis-cli -h $REDIS_HOST INFO keyspace
# Sample each Laravel subsystem's prefix
redis-cli -h $REDIS_HOST --scan --pattern 'laravel_database_laravel_cache:*' | head -20
redis-cli -h $REDIS_HOST --scan --pattern 'laravel_database_*session*' | head -20
redis-cli -h $REDIS_HOST --scan --pattern '*queues:*' | head -20
redis-cli -h $REDIS_HOST --scan --pattern 'horizon:*' | head -20
Use --scan, never KEYS *. KEYS is O(N) and blocks the server for the duration.
Sort what you find into two piles:
Disposable. Cache entries, tagged cache sets and stampede locks, Pennant flag resolutions, rate-limiter counters. Losing these costs you a latency spike while things refill. If you lean on fine-grained rate limiting on API routes, be aware that a cold limiter briefly hands every client a full quota.
Not disposable. Session payloads (users get logged out), queued job payloads, and anything in *:reserved — a job a worker has claimed but not yet finished. Horizon's metrics are somewhere in between: historical trend data you would rather keep, but nothing operational depends on it.
Note whether cache and queue share one instance. If they do, php artisan cache:clear can take queue keys with it depending on how your prefix is configured. Splitting them into separate databases, or separate instances, is the cheapest improvement you will make during this migration.
Confirm your Redis version before you trust the RDB file#
This is the step that decides your entire migration path, and it is the one most guides skip. Valkey reads RDB and AOF files produced by Redis OSS 7.2 and earlier. Redis CE 7.4 and later write data files Valkey cannot read. If you are on 7.4+, a file copy is off the table and you need replication or a key-by-key migration instead.
redis-cli -h $REDIS_HOST INFO server | grep -E 'redis_version|redis_mode|server_name'
redis_version:7.2.5
redis_mode:standalone
Anything from 2.x through 7.2.x means a physical file copy is a straight upgrade. 7.4 or later means you use REPLICAOF or MIGRATE, both of which read from the live keyspace rather than the on-disk format.
One trap for later: Valkey reports redis_version:7.2.4 in INFO forever, for backward compatibility. To tell what you are actually connected to, read server_name and valkey_version instead. Any health check that asserts on redis_version will silently pass against Valkey and tell you nothing.
Also check the commands your app depends on. If you sit on 7.4+ and use a command that post-dates the fork point, verify it exists in current Valkey before you commit.
Stand up Valkey alongside the existing Redis instance#
Run both at once. Valkey gets its own port and its own volume, and nothing about the running application changes yet. This is the safest possible starting state.
# docker-compose.yml
services:
valkey:
image: valkey/valkey:9.1.1-alpine
ports:
- "6380:6379" # 6379 still belongs to Redis
volumes:
- valkey-data:/data
command:
- valkey-server
- --maxmemory
- 1gb
- --maxmemory-policy
- allkeys-lru # cache workload: evict, don't error
- --appendonly
- "yes"
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
volumes:
valkey-data:
Set maxmemory and maxmemory-policy explicitly. The shipped valkey.conf leaves maxmemory unset and maxmemory-policy at noeviction, exactly like Redis — which means a cache instance that fills up starts rejecting writes instead of evicting cold keys. Cache::put() throwing under load is a far worse failure than a cache miss, and it is the single most common self-inflicted wound in a self-hosted move.
valkey.conf accepts Redis-style configuration directives, so your existing redis.conf can be copied across as-is and extended with Valkey-specific options.
If your workers run in containers, this is a good moment to confirm they still come up clean — the same reasoning behind adding a Docker HEALTHCHECK to a Laravel queue worker applies to the datastore they depend on.
On bare metal, the packages are valkey-server and valkey-tools, and the service unit is valkey-server rather than redis-server.
Point a staging Laravel app at Valkey and run the test suite#
Prove compatibility somewhere that does not matter. Copy your staging environment, change one line, and run everything. No application code, no config/database.php edit, no new package.
# .env on staging — this is the whole change
REDIS_HOST=127.0.0.1
REDIS_PORT=6380
# Everything below stays exactly as it was
REDIS_CLIENT=phpredis
CACHE_STORE=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
php artisan config:clear
php artisan test
php artisan tinker --execute 'dump(Illuminate\Support\Facades\Redis::connection()->info("server")["valkey_version"] ?? "not valkey");'
There is no valkey cache driver, queue connection or session driver in Laravel, and adding one would be pointless — the redis driver is a RESP client, and Valkey speaks RESP. If a test fails here it will be about TLS, auth or an eviction policy, not about the datastore being Valkey.
TLS is the one config edit you may genuinely need. Managed Redis almost always enforces it; a self-hosted Valkey almost never does. Going the other way, from managed TLS to plaintext internal networking, means dropping the scheme key:
// config/database.php — managed Redis over TLS
'default' => [
'scheme' => 'tls', // remove this for a plaintext self-hosted Valkey
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'context' => [
// 'stream' => ['verify_peer' => false], // only for a private CA
],
],
Copy the RDB snapshot into Valkey for a warm start#
If you cleared the version check, hand Valkey the existing snapshot. The formats are compatible, so Valkey loads it on boot and comes up with your real keyspace rather than an empty one. Record the key count first — that is your verification number.
# 1. Record the source key count
redis-cli -h $OLD_HOST INFO keyspace # db0:keys=6286,expires=6280,avg_ttl=0
# 2. Find where the RDB lives, then write a fresh one
redis-cli -h $OLD_HOST CONFIG GET dir dbfilename
redis-cli -h $OLD_HOST BGSAVE
# 3. Or pull it over the wire if you have no filesystem access
redis-cli -h $OLD_HOST --rdb /tmp/dump.rdb
# 4. Place it in Valkey's data dir and start clean
docker compose stop valkey
docker cp /tmp/dump.rdb "$(docker compose ps -q valkey)":/data/dump.rdb
docker compose start valkey
# 5. Verify the count matches step 1
redis-cli -h 127.0.0.1 -p 6380 INFO keyspace
If you enabled appendonly yes, disable it for this first boot. With AOF on, Valkey loads the append-only file and ignores the RDB entirely — you get a silently empty instance and a confusing five minutes. Turn AOF back on once the key count checks out.
Managed providers are the awkward case. Most block BGSAVE, CONFIG GET dir and filesystem access, and many block REPLICAOF too. If --rdb is refused, your remaining options are REPLICAOF from a self-hosted Valkey pointed at the managed Redis, or MIGRATE for a hand-picked set of critical keys. If both are blocked, accept a cold cache and move sessions with a dual-read window instead.
Managed Valkey is widely available now if you would rather not self-host: AWS ElastiCache for Valkey, Google Cloud Memorystore for Valkey, DigitalOcean Managed Caching for Valkey and Aiven for Valkey all ship it, and AWS prices Valkey nodes below the equivalent Redis OSS ones.
Split the Laravel cutover by concern instead of flipping everything at once#
Do not change one REDIS_HOST and restart everything. Move each subsystem in the order of what a mistake costs you, using per-connection overrides so each move is independent and separately revertible.
// config/database.php — one connection per concern during the migration
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
],
'cache' => [
'host' => env('REDIS_CACHE_HOST', env('REDIS_HOST', '127.0.0.1')),
'port' => env('REDIS_CACHE_PORT', env('REDIS_PORT', '6379')),
'database' => env('REDIS_CACHE_DB', '1'),
],
'default' => [ // sessions, locks, broadcasting
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
],
],
The order:
- Cache. Point
REDIS_CACHE_HOSTat Valkey. Worst case is a latency blip while it refills. - Locks and rate limiters. Brief double-spend window on anything guarded by
Cache::lock(), so pick a quiet hour. - Sessions. Users get logged out unless the session keys came across in the RDB copy. If they did, this is invisible.
- Broadcasting. Only relevant if you broadcast over Redis. If you have already moved to Reverb's database driver, there is nothing to do here.
- Queues. Last, and only after draining.
Leave twenty-four hours between cache and sessions if you can. The same staged discipline that makes scaling Laravel queues in production survivable applies here: change one thing, watch a full traffic cycle, then change the next.
Drain the queue before you move the queue connection#
Flipping QUEUE_CONNECTION while jobs sit in *:reserved orphans them. The payload stays in the old instance, no worker is watching it any more, and it will not surface as an error — the job simply never runs. Drain first, always.
# 1. Stop accepting new work at the source (maintenance mode, or pause dispatchers)
php artisan down --render="errors::503"
# 2. Tell workers to finish the current job and exit
php artisan horizon:terminate # Horizon
php artisan queue:restart # plain workers under Supervisor
# 3. Watch until every queue reads zero
watch -n 2 'redis-cli -h $REDIS_HOST llen "laravel_database_queues:default"; \
redis-cli -h $REDIS_HOST zcard "laravel_database_queues:default:reserved"; \
redis-cli -h $REDIS_HOST zcard "laravel_database_queues:default:delayed"'
# 4. Only when pending AND reserved are both 0, flip the host and bring workers back
Delayed jobs are the ones people forget. *:delayed can hold scheduled work days out, and it will not drain on its own. Either wait for the sorted set to empty, or move those keys across with MIGRATE before the cutover.
Both horizon:terminate and queue:restart are graceful — they let the in-flight job finish. If your workers run under Supervisor, confirm stopwaitsecs is longer than your slowest job before you rely on that, which is covered in more detail in keeping Laravel queue workers alive with Supervisor.
Horizon itself needs nothing special. It reads the redis queue connection and writes its own metrics to Redis; both work unchanged against Valkey. If you skip migrating the horizon:* keys you lose historical throughput and wait-time trends, and nothing else. Everything in monitoring Laravel queues with Horizon behaves identically afterwards.
Flip REDIS_HOST and verify each Laravel subsystem against Valkey#
Change the host, clear config, restart the workers, then exercise every subsystem deliberately. Do not infer from "the site loads" that sessions and locks are fine.
sed -i 's/^REDIS_PORT=6379/REDIS_PORT=6380/' .env
php artisan config:clear && php artisan config:cache
php artisan queue:restart
php artisan up
// A single pass over everything that touches the datastore
use Illuminate\Support\Facades\{Bus, Cache, Redis};
// Cache round trip
Cache::put('valkey:probe', 'ok', 60);
assert(Cache::get('valkey:probe') === 'ok');
// Atomic lock — proves Lua scripting works
$lock = Cache::lock('valkey:probe:lock', 10);
assert($lock->get() === true);
$lock->release();
// Queue round trip — dispatch, then confirm the worker drained it
Bus::dispatch(new \App\Jobs\NoopJob);
sleep(2);
assert(Redis::llen('queues:default') === 0);
// Confirm which server you are actually talking to
dump(Redis::connection()->info('server')['valkey_version'] ?? 'still redis');
Then log in through a browser, reload twice, and confirm the session survives. Sessions are the one thing a tinker script will not catch, because your CLI has no cookie.
Lua scripts carry over untouched — Valkey keeps the redis namespace alongside its own server namespace, so Redis::eval() calls and Laravel's internal queue scripts behave the same. That is why the lock probe above matters: it exercises the scripting path in one line.
Watch memory, latency and eviction for one full traffic cycle#
A migration that looks clean at 10pm can fall over at 9am when real traffic arrives. Take a baseline from the old instance first, then compare after a full day and night.
# Baseline from Redis, before you decommission it
redis-cli -h $OLD_HOST INFO memory | grep -E 'used_memory_human|maxmemory_human|maxmemory_policy'
redis-cli -h $OLD_HOST INFO stats | grep -E 'evicted_keys|keyspace_hits|keyspace_misses'
# Same fields on Valkey after 24 hours
redis-cli -h $NEW_HOST -p 6380 INFO memory
redis-cli -h $NEW_HOST -p 6380 INFO stats
# Latency over time, not a single sample
redis-cli -h $NEW_HOST -p 6380 --latency-history -i 5
Four numbers matter. maxmemory_policy should be what you set, not noeviction. evicted_keys climbing steadily on a cache instance is healthy; on a session or queue instance it means you are silently losing data and the policy is wrong. keyspace_misses should trend back toward the old ratio as the cache refills. used_memory_human should land near the old figure — a large gap usually means the RDB did not load or AOF overrode it.
Keep the old instance running and warm for this entire window. Rollback is REDIS_PORT=6379, config:cache, queue:restart — the same one-switch-back property that makes blue-green deployments with an Nginx upstream worth the extra runtime cost.
Decommission the Redis instance and update your infrastructure code#
Once you have a clean 24-hour cycle, retire the old instance properly. Delete the container or terminate the managed node, remove the service from docker-compose.yml and your Terraform or Ansible, and take a final RDB copy to cold storage before anything is destroyed.
Then clean up the migration scaffolding: fold REDIS_CACHE_HOST and REDIS_CACHE_PORT back into the plain REDIS_HOST and REDIS_PORT now that everything points at one place, and update any health check or alert that asserts on redis_version — it will keep reporting 7.2.4 forever and tell you nothing about the server you are running.
One caveat if you run Redis Cluster: Laravel 13.5.0 added first-class Redis Cluster support for the queue driver and the concurrency limiter, automatically wrapping queue names in hash tags (queues:{default}) so the queue's Lua scripts stop throwing CROSSSLOT errors. That fix was written against AWS ElastiCache Serverless running Valkey. Cluster mode is where compatibility claims get thinnest in both directions, so if you are on a cluster, be on 13.5.0 or later and verify in staging rather than assuming.
Next steps worth taking while the context is fresh: revisit your eviction policy and key naming against the complete guide to caching in Laravel, and if the queue drain exposed jobs you could not account for, scaling Laravel queues in production covers the per-queue separation that makes the next drain a five-minute job.
FAQ#
Is Valkey compatible with Laravel?
Yes. Valkey is a fork of Redis 7.2.4 that keeps the RESP wire protocol, the command set and the RDB and AOF file formats, so Laravel's redis cache, queue, session and broadcast drivers work against it unmodified. Both supported PHP clients — phpredis and Predis — connect to Valkey without code changes. You point REDIS_HOST at Valkey and nothing else moves.
Do I need a special Valkey driver or package for Laravel?
No, and there isn't one. Laravel has no valkey cache store, queue connection or session driver, because the existing redis driver is a RESP client and Valkey speaks RESP. Keep CACHE_STORE=redis, QUEUE_CONNECTION=redis and SESSION_DRIVER=redis exactly as they are. phpredis lists Valkey as a supported server and detects it from the HELLO response; Predis describes itself as a Redis/Valkey client.
What is the difference between Valkey and Redis?
Valkey forked from Redis 7.2.4 after Redis changed its licence, and is now a BSD-3-Clause project under the Linux Foundation. Everything up to the fork point is identical: protocol, commands, configuration directives and persistence formats. Divergence starts after 7.2 — Valkey has its own 8.x and 9.x feature lines, and Redis CE 7.4 and later write RDB files Valkey cannot read. Practically, that means migrating to Valkey is easy from Redis 7.2 or earlier and harder from 7.4+.
Can I migrate Redis data to Valkey without downtime?
Close to it, but not quite zero. The lowest-downtime path is replication: run REPLICAOF <redis-host> <redis-port> on Valkey, wait for master_link_status:up, point your app at Valkey, then run REPLICAOF NO ONE to promote it. Copying the RDB file is simpler but needs a short window with connections closed. Many managed providers block both REPLICAOF and BGSAVE, in which case you migrate specific keys with MIGRATE and accept a cold cache.
Does Laravel Horizon work with Valkey?
Yes, with no configuration change. Horizon reads from the redis queue connection and stores its own metrics and job payloads in the same instance, and both work against Valkey. If you don't migrate the horizon:* keys you lose historical throughput and wait-time charts but nothing operational. Run php artisan horizon:terminate and confirm zero pending, reserved and delayed jobs before you move the queue connection.
Will phpredis and Predis both work with Valkey?
Both work. The phpredis README lists Valkey alongside Redis, Dragonfly and KeyDB as supported servers, and the extension identifies the server type from the HELLO response rather than assuming Redis. Predis has advertised itself as a "Redis/Valkey client for PHP" since 2024. Stay on a current phpredis (6.x) if you use cluster mode — Laravel's cluster connector relies on options added in phpredis 5.3.2 and later.