> ## Documentation Index
> Fetch the complete documentation index at: https://cloud.laravel.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrate from Redis by Upstash to Laravel Valkey

> Learn how to migrate your application's cache, sessions, queues, and Redis data from Redis by Upstash to Laravel Valkey.

[Redis by Upstash](/docs/resources/caches/redis) is deprecated and scheduled to be sunset. Its replacement is [Laravel Valkey](/docs/resources/caches/valkey), a fully managed, Redis-compatible KV store with better performance and per-second billing. This guide walks through moving an environment from an Upstash cache to a Valkey cache.

<Warning>
  Redis by Upstash pricing changes on **November 30, 2026**. Eligible customers who migrate to [Laravel Valkey](/docs/resources/caches/valkey) and delete their Upstash cache before that date will receive a Cloud credit based on two months of the cache's monthly price. See the [pricing page](/docs/pricing#redis-by-upstash) for the current and upcoming prices.

  Existing Redis by Upstash caches will remain available until **August 31, 2027**, when the service is sunset on Laravel Cloud.
</Warning>

Valkey is compatible with the Redis API your application already uses, so your application code does not need to change. Migrating is really about two things: pointing your environment at the new store, and deciding what, if anything, must happen to the data in the old one.

[Laravel Valkey](/docs/resources/caches/valkey) is the right destination for most applications. Enterprise customers on [Private Cloud](/docs/private-cloud) may instead migrate to a managed AWS [ElastiCache](/docs/private-cloud/elasticache) cache, available in both Valkey and Redis OSS engines, which adds automatic failover and multi-AZ deployments. Either way the destination is a Redis-compatible store, so every path in this guide applies unchanged. To provision ElastiCache, [contact us](https://cloud.laravel.com/enterprise).

## Before you start

An environment has one attached cache at a time. Replacing it updates the injected `REDIS_*` 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:

| Setting                           | What you are storing                                               |
| --------------------------------- | ------------------------------------------------------------------ |
| `CACHE_STORE=redis`               | Application cache, plus cache-based rate limiters and atomic locks |
| `SESSION_DRIVER=redis`            | User sessions                                                      |
| `QUEUE_CONNECTION=redis`          | Queued jobs, including delayed jobs and pending retries            |
| `Redis` facade usage in your code | Anything your application writes to Redis directly                 |

Then pick your path:

| Path                                       | Use when                                                         | Downtime           |
| ------------------------------------------ | ---------------------------------------------------------------- | ------------------ |
| 1. [Start fresh](#start-fresh)             | Cache only, or you can tolerate losing sessions and pending jobs | None               |
| 2. [Drain your queues](#drain-your-queues) | You must not lose queued jobs or sessions                        | None               |
| 3. [Copy the data](#copy-the-data)         | Long-lived data you cannot lose or wait out                      | Maintenance window |

## Start fresh

If Upstash only backs your application's cache, there is no data worth moving. Caches rebuild themselves.

<Steps>
  <Step title="Attach a Valkey cache">
    On your environment's canvas, open the "..." menu on the cache card and choose "Replace", then select a [Laravel Valkey](/docs/resources/caches/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.
  </Step>

  <Step title="Deploy">
    Redeploy the environment. The injected `REDIS_*` variables now point at Valkey.
  </Step>

  <Step title="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.
  </Step>
</Steps>

The new cache starts empty, so cache reads miss until it rebuilds. On high-traffic applications, deploy during a low-traffic window to limit the temporary increase in database load. Redis-backed sessions reset, so users must sign in again and forms submitted across the cutover may receive a `419 Page Expired` response. Use the [session drain](#keeping-users-signed-in) 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.

<Warning>
  If `QUEUE_CONNECTION=redis`, any jobs still in the old store, including delayed jobs, will never be processed after the swap. Check before you cut over by running `php artisan queue:monitor redis:default` from the Commands tab (list each queue name you dispatch to). If the count is not zero and those jobs matter, use [Path 2](#drain-your-queues) instead.
</Warning>

## 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.

<Steps>
  <Step title="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.
  </Step>

  <Step title="Define the drain connections">
    Add a Redis connection for the old store in `config/database.php`:

    ```php theme={null}
    // config/database.php, inside the 'redis' array...

    'upstash' => [
        'host' => env('UPSTASH_REDIS_HOST'),
        'password' => env('UPSTASH_REDIS_PASSWORD'),
        'port' => env('UPSTASH_REDIS_PORT', '6379'),
        'scheme' => env('UPSTASH_REDIS_SCHEME', 'tls'),
        'database' => env('REDIS_DB', '0'),
    ],
    ```

    And a queue connection that uses it in `config/queue.php`:

    ```php theme={null}
    // config/queue.php, inside the 'connections' array...

    'upstash' => [
        'driver' => 'redis',
        'connection' => 'upstash',
        'queue' => env('REDIS_QUEUE', 'default'),
        'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
        'block_for' => null,
        'after_commit' => false,
    ],
    ```

    Commit these changes.
  </Step>

  <Step title="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](#start-fresh) apply to them here.
  </Step>

  <Step title="Run a drain worker">
    Add a [background process](/docs/queues#app-cluster-background-processes) 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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

If you run Horizon, keep it pointed at the default connection and drain the old store with a plain `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](https://nightwatch.laravel.com) traces queue jobs independently of which store backs them.

<Tip>
  If you are rethinking your queue setup anyway, consider draining to [managed queues](/docs/queues#managed-queues) instead of Valkey. The pattern is identical: create the managed queue so new jobs dispatch to the `cloud` connection, then drain the old Redis store with the same worker and monitor commands.
</Tip>

## 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 expire `SESSION_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](#drain-your-queues) setup, define a cache store for it in `config/cache.php`:

```php theme={null}
// config/cache.php, inside the 'stores' array...

'upstash-sessions' => [
    'driver' => 'redis',
    'connection' => 'upstash',
],
```

Create the handler:

```php theme={null}
<?php

namespace App\Sessions;

use SessionHandlerInterface;

class DrainingSessionHandler implements SessionHandlerInterface
{
    public function __construct(
        protected SessionHandlerInterface $new,
        protected SessionHandlerInterface $old,
    ) {}

    public function read($id): string|false
    {
        return $this->new->read($id) ?: $this->old->read($id);
    }

    public function write($id, $data): bool
    {
        return $this->new->write($id, $data);
    }

    public function destroy($id): bool
    {
        $this->new->destroy($id);
        $this->old->destroy($id);

        return true;
    }

    public function open($path, $name): bool
    {
        return true;
    }

    public function close(): bool
    {
        return true;
    }

    public function gc($max_lifetime): int
    {
        return 0; // Expiry is handled by the cache TTLs...
    }
}
```

Register it as a session driver in the `boot` method of `app/Providers/AppServiceProvider.php`:

```php theme={null}
use App\Sessions\DrainingSessionHandler;
use Illuminate\Session\CacheBasedSessionHandler;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Session;

Session::extend('drain', function ($app) {
    $minutes = $app['config']['session.lifetime'];

    return new DrainingSessionHandler(
        new CacheBasedSessionHandler(Cache::store('redis'), $minutes),
        new CacheBasedSessionHandler(Cache::store('upstash-sessions'), $minutes),
    );
});
```

Set `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 the `Redis` 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.

<Steps>
  <Step title="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: `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](#drain-your-queues).

    Set the maintenance mode variables so the "down" state is shared across instances and survives the cache swap:

    ```ini theme={null}
    APP_MAINTENANCE_DRIVER=cache
    APP_MAINTENANCE_STORE=database
    ```

    The store must be `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`:

    ```php theme={null}
    // config/database.php, inside the 'redis' array...

    'upstash' => [
        'host' => env('UPSTASH_REDIS_HOST'),
        'password' => env('UPSTASH_REDIS_PASSWORD'),
        'port' => env('UPSTASH_REDIS_PORT', '6379'),
        'scheme' => env('UPSTASH_REDIS_SCHEME', 'tls'),
        'database' => 0,
        'prefix' => '',
    ],

    'valkey' => [
        'host' => env('VALKEY_REDIS_HOST'),
        'username' => env('VALKEY_REDIS_USERNAME'),
        'password' => env('VALKEY_REDIS_PASSWORD'),
        'port' => env('VALKEY_REDIS_PORT', '6379'),
        'scheme' => 'tls',
        'database' => 0,
        'prefix' => '',
    ],
    ```

    The empty `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](#the-migration-command) to your application, commit, and deploy. Nothing has changed for your users yet.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Freeze writes">
    Run `php artisan down` from the Commands tab, then stop everything else that writes to Redis: pause any [managed queues](/docs/queues#pausing-and-purging), 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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

### 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 sets `REDIS_CLIENT=predis`, a few calls (such as `zAdd` argument order) need adapting.

```php theme={null}
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Redis\Connections\Connection;
use Illuminate\Support\Facades\Redis;

class MigrateCache extends Command
{
    protected $signature = 'cache:migrate
                            {--from=upstash : The source Redis connection}
                            {--to=valkey : The destination Redis connection}
                            {--chunk=1000 : Keys per SCAN iteration}
                            {--dry-run : Count keys by type without copying}';

    protected $description = 'Copy every key from one Redis connection to another';

    public function handle(): int
    {
        $source = Redis::connection($this->option('from'));
        $destination = $this->option('dry-run') ? null : Redis::connection($this->option('to'));

        $counts = [];
        $cursor = null;

        do {
            [$cursor, $keys] = $source->scan(
                $cursor, ['match' => '*', 'count' => (int) $this->option('chunk')]
            ) ?: [0, []];

            foreach ($keys as $key) {
                $type = $this->typeOf($source, $key);

                if ($destination && ! $this->copy($source, $destination, $key, $type)) {
                    $this->warn("Skipped [{$key}] of unsupported type [{$type}].");

                    continue;
                }

                $counts[$type] = ($counts[$type] ?? 0) + 1;
            }
        } while ((int) $cursor !== 0);

        $this->table(
            ['Type', $destination ? 'Copied' : 'Counted'],
            collect($counts)->map(fn ($count, $type) => [$type, $count])->values()
        );

        return static::SUCCESS;
    }

    protected function copy(Connection $source, Connection $destination, string $key, string $type): bool
    {
        $ttl = $source->pttl($key);

        if ($ttl === -2) {
            return true; // The key expired mid-scan...
        }

        $destination->del($key);

        $copied = match ($type) {
            'string' => $destination->set($key, $source->get($key)),
            'hash' => $destination->hmset($key, $source->hgetall($key)),
            'list' => $destination->rpush($key, ...$source->lrange($key, 0, -1)),
            'set' => $destination->sadd($key, ...$source->smembers($key)),
            'zset' => $destination->zadd($key, ...collect($source->zrange($key, 0, -1, true))
                ->flatMap(fn ($score, $member) => [$score, $member])->all()),
            default => false,
        };

        if ($copied !== false && $ttl > 0) {
            $destination->pexpire($key, $ttl);
        }

        return $copied !== false;
    }

    protected function typeOf(Connection $connection, string $key): string
    {
        $type = $connection->type($key);

        return match ($type) {
            \Redis::REDIS_STRING => 'string',
            \Redis::REDIS_SET => 'set',
            \Redis::REDIS_LIST => 'list',
            \Redis::REDIS_ZSET => 'zset',
            \Redis::REDIS_HASH => 'hash',
            \Redis::REDIS_STREAM => 'stream',
            default => is_string($type) ? strtolower($type) : 'unknown',
        };
    }
}
```

<Note>
  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.
</Note>

## 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](/docs/api/cli). Ask the agent to assess the application first, then approve each user-facing or irreversible step yourself.

```text theme={null}
Read https://cloud.laravel.com/docs/knowledge-base/migrate-upstash-to-valkey and assess this
application's migration from Redis by Upstash to Laravel Valkey. Make no changes yet.

Inspect the Cloud environment's CACHE_STORE, SESSION_DRIVER, and QUEUE_CONNECTION settings; search
the repository for Redis usage, delayed job dispatches, and queue names; and check each queue's
backlog. Recommend a migration path, describe the cutover effects, and show the complete plan.

After I approve the plan, make the code changes on a branch and show me the diff before committing.
Stop and ask before swapping the cache, deploying, enabling maintenance mode, or deleting anything.
Before teardown, verify that the old queue is empty or that the source and destination key counts
match. Do not delete the Upstash cache until I confirm the application is healthy.
```

## 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 direct `Redis` usage) so the team can help quickly.
