Before you start
An environment has one attached cache at a time. Replacing it updates the injectedREDIS_* environment variables on the next deployment. Detaching a KV store does not delete it, so your Upstash cache remains available and billable until you delete it from your organization’s Resources page. Its credentials remain valid, allowing a side-by-side migration.
The UPSTASH_* and VALKEY_* variables used in this guide are temporary and used only by the migration tooling. Remove them when the migration is complete.
First, work out what your application actually keeps in its KV store, since that determines how careful you need to be:
Then pick your path:
Start fresh
If Upstash only backs your application’s cache, there is no data worth moving. Caches rebuild themselves.1
Attach a Valkey cache
On your environment’s canvas, open the ”…” menu on the cache card and choose “Replace”, then select a Laravel Valkey cache, creating one if needed. Replacing only re-points the environment at the new cache; nothing changes for your running application until the next deployment.
2
Deploy
Redeploy the environment. The injected
REDIS_* variables now point at Valkey.3
Clean up
Once you have verified the application, delete the Upstash cache from your organization’s Resources page. Detached KV stores continue to bill until deleted.
419 Page Expired response. Use the session drain if that is unacceptable. Atomic locks and rate limiters also reset, so a withoutOverlapping scheduled task or a ShouldBeUnique job could run twice during the deployment.
Drain your queues
Queue data changes while your application runs, so do not copy it between stores. Instead, run both stores side by side. New jobs dispatch to Valkey while a worker drains the jobs that remain on Upstash.1
Save the Upstash credentials
Before swapping the cache, copy the current values of
REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, and REDIS_SCHEME from your environment’s variables into new custom variables: UPSTASH_REDIS_HOST, UPSTASH_REDIS_PORT, UPSTASH_REDIS_PASSWORD, and UPSTASH_REDIS_SCHEME. Because these mirror the values your application connects with today, they are guaranteed to be in the right format.2
Define the drain connections
Add a Redis connection for the old store in And a queue connection that uses it in Commit these changes.
config/database.php:config/queue.php:3
Swap the cache and deploy
Attach the Valkey cache to the environment and deploy. From this moment, every
dispatch() lands on Valkey, since the default Redis connection now resolves to the new store. Your cache and sessions cut over in the same deployment, so the expectations from Path 1 apply to them here.4
Run a drain worker
Add a background process running
php artisan queue:work upstash and deploy. If you dispatch to multiple queues, list them: php artisan queue:work upstash --queue=default,emails. For a small backlog with no delayed jobs, a one-off run of php artisan queue:work upstash --stop-when-empty from the Commands tab is enough.5
Watch until empty
Check progress from the Commands tab with
php artisan queue:monitor upstash:default (again, listing each queue name). The reported size includes delayed and reserved jobs, so zero means genuinely done.Be careful relying on --stop-when-empty as your completion signal: it exits as soon as no job is ready right now, even if delayed jobs are still scheduled for the future. queue:monitor is the source of truth.6
Tear down
Retry or delete failed jobs before removing the old connection, since a retried job is re-dispatched onto the connection recorded on the failed job. Then remove the drain worker, the two config blocks, and the
UPSTASH_* variables, deploy, and delete the Upstash cache from the Resources page.queue:work background process. Horizon’s internal state lives in Redis, so its dashboard metrics reset at the swap. For job-level visibility that survives the migration, Laravel Nightwatch traces queue jobs independently of which store backs them.
Keeping users signed in
By default, sessions reset at the swap and users sign in again. If that is unacceptable (long-lived logins, carts kept in the session, an active admin panel), sessions can be drained just like queues, using a temporary read-through session handler that checks the new store first and falls back to the old one. Laravel saves the session at the end of every request. After cutover, the first request from a returning visitor reads their session from Upstash through the fallback and writes it to Valkey. Redis-backed sessions expireSESSION_LIFETIME minutes (120 by default) after the user’s last request, so keep the fallback in place for one session lifetime after cutover. Any session that remains on Upstash after that period has expired normally.
Reusing the upstash Redis connection from the drain setup, define a cache store for it in config/cache.php:
boot method of app/Providers/AppServiceProvider.php:
SESSION_DRIVER=drain so it deploys together with the cache swap. Note that destroy clears both stores, so signing out cannot be undone by the fallback, and rolling back mid-window is just an environment variable change. On Laravel Cloud, the injected configuration points the default and cache Redis connections at the same store and database, so the keys written here line up with the built-in redis session driver.
Once one session lifetime has passed after cutover, set SESSION_DRIVER back to redis and re-deploy before deleting the Upstash cache. With the driver reverted, remove the handler, cache store, and connection with the rest of the drain teardown. Applications with very long session lifetimes should note the old cache must stay alive (and billing) for the whole window.
Copy the data
Sometimes the data itself must survive: your application stores long-lived values via theRedis facade, sessions you cannot invalidate, cache entries too expensive to rebuild, or delayed jobs scheduled further out than you can reasonably drain.
Upstash does not expose Redis replication commands (REPLICAOF, PSYNC) to clients, so a live, zero-downtime replication into Valkey is not possible. Restoring an Upstash backup is not possible either: Upstash runs Redis 8.x, whose export format Valkey, which is Redis 7.2-compatible, cannot load, and DUMP/RESTORE fails for the same reason. The reliable path is a client-side copy that reads each key with its type’s native commands and writes it to the new store, which is exactly what the Artisan command below does.
A copy like this is point-in-time. Anything written to the old store after its key has been copied is silently lost, so you must freeze writes for the duration. This path requires a maintenance window roughly the length of the copy plus a deployment.
1
Prepare (no downtime yet)
Create the Valkey cache but do not attach it. Click the ”…” icon next to the cache on the Resources page, then “View credentials”, and add the connection details as custom environment variables: The store must be The empty
VALKEY_REDIS_HOST, VALKEY_REDIS_PORT, VALKEY_REDIS_USERNAME, and VALKEY_REDIS_PASSWORD. Copy the current injected REDIS_* values into UPSTASH_* variables as in Path 2.Set the maintenance mode variables so the “down” state is shared across instances and survives the cache swap:database, not redis. Maintenance state kept in the cache you are migrating away from would be lost mid-migration.Add both Redis connections to config/database.php:prefix matters. SCAN returns raw keys that already carry your application’s key prefix, so the copy connections must not apply a prefix of their own or every key would be double-prefixed on write. With an empty prefix, keys are copied byte-for-byte.Finally, add the cache:migrate command to your application, commit, and deploy. Nothing has changed for your users yet.2
Count the keyspace
From the Commands tab, run
php artisan cache:migrate --dry-run to count keys by type without copying anything. As a rough planning figure, expect the copy to move a few hundred keys per second, and size your maintenance window accordingly.3
Freeze writes
Run
php artisan down from the Commands tab, then stop everything else that writes to Redis: pause any managed queues, remove or scale down queue:work background processes, and disable the Scheduler toggle on your compute cluster (these changes deploy with the environment). php artisan down only stops web traffic; workers and the scheduler keep running until you stop them explicitly.4
Copy
Run
php artisan cache:migrate from the Commands tab. The command replaces each destination key before writing, so it is idempotent and safe to re-run if interrupted.5
Swap and verify
Attach the Valkey cache to the environment and deploy. Then compare key counts by running
php artisan cache:migrate --from=valkey --dry-run against the source’s earlier count. A small difference is normal, since keys with short TTLs expire during the window.6
Resume
Run
php artisan up, re-enable the scheduler and workers, and resume any paused queues. After a burn-in period, remove the migration command, config blocks, and UPSTASH_* / VALKEY_* variables, and delete the Upstash cache from the Resources page.The migration command
Use this command to copy every key from one Redis connection to another using each type’s native read and write commands, preserving TTLs. It is written for phpredis, Laravel’s default Redis client. If your application setsREDIS_CLIENT=predis, a few calls (such as zAdd argument order) need adapting.
The command skips Redis streams, since consumer group state cannot be copied faithfully this way, and it reads each key’s value in a single call, so extremely large individual keys (a hash with millions of fields, for example) may need chunked copying with
HSCAN instead. Most Laravel applications are unaffected by either limitation.Using an AI agent
An AI coding agent can help with this migration when it has this guide, your application’s repository, and an authenticated Laravel Cloud CLI. Ask the agent to assess the application first, then approve each user-facing or irreversible step yourself.Need help?
If you get stuck at any point, or you have an unusual cache workload you are not sure how to migrate, contact support from the Help portal in your Laravel Cloud dashboard before cutting over. It is far easier to talk through the approach in advance than to recover a migration that has already gone sideways. When you reach out, mention which path you are on and what your KV store is used for (cache, sessions, queues, or directRedis usage) so the team can help quickly.
