Migrating files with zero downtime: what read-through does, and what it doesn't

Arnaud Pointet · · 9 min read

Lire cet article en français

This article starts from an exercise you can do in your browser: Migrating files as they are read with read-through →

Laravel 13.26 added a read-through disk driver. In the Practice exercise that introduces it, a controller serves documents in the middle of a migration: it reads from the new storage, falls back to the old one, and copies files over along the way. The driver replaces those fifteen lines with three lines of configuration.

That's good news, but a storage migration is more than that configuration. I've run several of them (local disk to S3, one bucket to another, one provider to another), and falling back on read is only one part of the job. This article starts from the exercise and goes all the way to the day you switch off the old storage.

The exercise: three lines of configuration

Before, the controller did everything itself:

$new = Storage::disk('new');
if ($new->exists($name)) {
    return $this->asText($new->get($name));
}

$old = Storage::disk('old');
if ($old->exists($name)) {
    $contents = $old->get($name);
    $new->put($name, $contents);

    return $this->asText($contents);
}

abort(404);

With Laravel 13.26, you declare a disk that combines the two:

// config/filesystems.php
'documents' => [
    'driver' => 'read-through',
    'primary' => 'new',
    'fallback' => 'old',
],

And the controller only knows about that one:

$documents = Storage::disk('documents');
abort_unless($documents->exists($name), 404);

return $this->asText($documents->get($name));

The win goes beyond fewer lines of code: the migration becomes a configuration detail, invisible to the rest of the application. The day it's over, you replace documents with a plain S3 disk, and not a single call to Storage::disk('documents') has to change.

What the driver actually does

The documentation is brief. The source code tells you more, and these details matter for what follows (the main operations, as of Laravel 13.26; move() and copy() on a file still sitting on the old disk only gained support in 13.27):

Operation Behavior
get(), readStream() reads primary; otherwise reads fallback, then writes a copy to primary
exists() true if the file is on either disk
put(), writeStream() writes to primary only
delete() deletes from both disks
files(), allFiles() list primary only
url(), size(), mimeType(), lastModified() read from whichever disk has the file, without copying it

Two design choices are particularly well thought out.

Deletes hit both disks. Otherwise, a file deleted from the new storage would linger on the old one, and the next read would bring it back. It's the classic bug in hand-rolled fallbacks, and I've seen it reach production: documents deleted at a customer's request (including GDPR erasure requests) that were back the next day.

The copy re-checks before writing. Between the moment the driver finds the file missing from primary and the moment it copies it over, another request may have written a newer version. The driver checks again just before copying so it doesn't overwrite it. The race window doesn't disappear entirely (two operations are never atomic on object storage), but it becomes very narrow.

What read-through doesn't migrate

The driver migrates what the application reads. Everything else stays on the old disk, and that's where migrations get bogged down.

Files nobody ever reads

An invoice from 2019, an inactive user's avatar, a forgotten attachment: most of an application's files are almost never read again. Read-through quickly migrates the hot files, then plateaus. It can happen that after three months only 20% of the files have moved, and the old storage is still indispensable.

So you always need a bulk backfill (see below). Read-through doesn't replace the copy: it lets you do it at your own pace, with no maintenance window, while the application is already running on the new storage.

Files served by URL

This is the most common trap. On S3, you rarely serve a file by reading it with PHP: you generate a URL, possibly a signed one, and the browser downloads straight from the bucket.

$documents->temporaryUrl($name, now()->addMinutes(5));

The driver does return a working URL: the one for the disk that holds the file. But it doesn't read the file, so it never copies it. An application that serves all its files by URL migrates nothing at all with read-through, even though everything appears to work.

Listings

files() and allFiles() only list the new disk. So during the migration, an "All your documents" page that lists a folder only shows the documents already migrated. An export, a disk-usage calculation, a cleanup of orphaned files: anything that enumerates instead of reading a known path gives the wrong result.

If the application lists files, the right answer often isn't a technical one: the database should know which files exist, not the storage. A migration is when that debt comes due.

Metadata

The copy is a plain write(). For readStream(), the driver first buffers the whole file (in memory up to 2 MB, then in a temporary file) before writing it to the new disk and handing it back to you: so the first Storage::download() of a large unmigrated file waits for the copy to finish before sending the first byte. The modification date becomes the date of the copy. Visibility (public or private) and HTTP headers (Content-Type, Cache-Control, Content-Disposition) take the new disk's defaults. If your application relies on lastModified() for HTTP caching, or on S3 headers set at upload time, check this before you migrate. It's not a flaw in the driver: it's true of almost any file copy.

Failed copies

By default, if copying to the new disk fails (insufficient permissions, quota reached), the driver still returns the content it read from the old one. That's the right call for the user, who gets their file. But it means a migration can stall without making a sound. The driver accepts a throw_on_promotion_failure option to throw an exception in that case. Failing that, keep an eye on the volume of reads served by the old disk: if it isn't going down, something is stuck.

Seeing the migration through

Here is the plan I follow, and read-through is only one step of it.

1. Write to the new disk, read from both

You switch the application over to the read-through disk. From that point on, every new or modified file goes straight to the new storage, and the old one no longer receives any writes. That's the real cutover, and it happens with zero downtime.

If several applications or several servers write to the old storage, they all have to switch before you move on to the next step. Otherwise, files keep landing on the old disk after the backfill has already gone past.

2. Backfill the rest in the background

A command walks the old disk and copies whatever is missing. It must be able to stop and resume without starting over, and never overwrite a newer version:

final class BackfillDocuments extends Command
{
    protected $signature = 'documents:backfill';

    public function handle(): int
    {
        $old = Storage::disk('old');
        $new = Storage::disk('new');
        $copied = $failures = 0;

        foreach ($old->allFiles() as $path) {
            if ($new->exists($path)) {
                continue; // already migrated, or rewritten since: leave it alone
            }
            $stream = $old->readStream($path);
            $ok = null !== $stream && $new->writeStream($path, $stream);
            if (\is_resource($stream)) {
                fclose($stream);
            }
            if (!$ok) {
                $this->error("Failed: $path");
                ++$failures;
                continue;
            }
            ++$copied;
        }

        $this->info("$copied document(s) copied, $failures failure(s).");

        return $failures ? self::FAILURE : self::SUCCESS;
    }
}

The exists() check before each copy makes the command idempotent: rerunning it after an interruption only copies what's missing. Streaming (readStream()) keeps memory usage flat, even for files of several hundred megabytes. Failures are counted separately: without the 'throw' => true option on the disks, readStream() returns null and writeStream() returns false, with no exception, and a command that doesn't check them would report success.

With millions of files, allFiles() becomes a problem in its own right: it loads the entire list into memory. At that point you're better off walking the table that references the files, in batches (chunkById()), or spreading the work across a queue, one job per prefix or per batch of IDs.

3. Verify

Before switching anything off, I check that the old storage is contained in the new one: every file from the old storage exists on the new one, with the same size, and for a sample, the same checksum (md5, or the ETag on S3 for files uploaded in a single part). Comparing total file counts would be meaningless: the new disk has received every file written since the migration started, and a delete removed the file from both sides. This is also the time to check the metadata that matters to the application.

4. Unplug the fallback, keep the old storage

The documents disk goes back to being a plain S3 disk. If you want to play it safe, there's an intermediate step: keep the read-through disk with the 'copy' => false option. The fallback still serves the odd forgotten file but no longer writes anything, and a rise in reads from the old storage points to a gap in the backfill. The old storage itself stays in place for a few weeks, read-only. If a file is missing, you can still recover it. Keep a close eye on 404 logs for documents during this period.

5. Switch it off

Only now. It's often the step people forget, and the old bucket is still being billed two years later.

The hidden cost of 404s

One last detail, which matters on storage billed per request: a missing file now costs two checks, one on each disk. If your application gets a lot of requests for files that don't exist (bots, old links, thumbnails not generated yet), the number of requests to your storage doubles for the entire migration. One more reason not to let it drag on.

My takeaways

The read-through driver cleanly solves the trickiest part of a migration: cutting over with zero downtime, without any business code knowing a migration is underway. Its design choices (deleting from both disks, re-checking before copying) show that its authors have done this kind of migration before.

But a migration doesn't finish on its own. Read-through is step 1 of 5. What makes the difference is the idempotent backfill, the verification, and the discipline to actually switch off the old storage.

To see the driver in action, the Practice exercise runs in the browser, with nothing to install: a real Laravel 13, two disks, and tests that check every behavior.

← All articles