DEV Community

Cover image for Migrate Laravel Uploads From Local Disk to S3 With Zero Downtime: Read-Through Disks and moveToDisk
Hafiz
Hafiz

Posted on Originally published at hafiz.dev

Migrate Laravel Uploads From Local Disk to S3 With Zero Downtime: Read-Through Disks and moveToDisk

Originally published at hafiz.dev


Every storage migration has the same shape. New uploads need to start landing in the new bucket now. The old files need to move over at some point. And for the whole time in between, a request for avatars/42.jpg has to work whether that file has moved yet or not.

Until this summer, Laravel gave you no help with the middle part. You either took a maintenance window, copied everything, flipped the config and hoped, or you wrote a two-disk lookup into every controller that touched a file. Both work. Both are worse than what 13.26 gives you.

Laravel 13.26 shipped a read-through filesystem driver that handles both disks behind one disk name. Laravel 13.32 added copyToDisk() and moveToDisk(), which are the primitives you want for the sweep at the end. Together they make the migration a config change plus one Artisan command.

This is the runbook I would follow to move a Laravel app's uploads from the local disk to S3, tested on 13.32 with 2,000 files, plus the six behaviours I verified against the framework source because the early write-ups disagreed on one of them.

What a read-through disk actually does

You define one disk that sits on top of two ordinary disks:

// config/filesystems.php
'disks' => [
    'local-legacy' => [
        'driver' => 'local',
        'root' => storage_path('app/private'),
        'throw' => true,
    ],

    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_BUCKET'),
        'throw' => true,
    ],

    'uploads' => [
        'driver' => 'read-through',
        'primary' => 's3',
        'fallback' => 'local-legacy',
    ],
],
Enter fullscreen mode Exit fullscreen mode

primary is where you are going. fallback is where you are. Both can be disk names or inline config arrays. Then every operation your app performs on uploads gets routed on purpose. I ran each of these against 13.32 with two local disks standing in for old and new, so this is what the code does, not what the docs promise:

Call on the read-through disk Which disk Copies the file to primary?
get(), readStream() Primary, then fallback on a miss Yes, once, on the fallback hit
exists(), size(), mimeType(), lastModified() Primary, then fallback No
put(), putFile(), writeStream() Primary only Not applicable
files(), allFiles(), directories() Primary only No
delete(), deleteDirectory() Fallback first, then primary No
url(), temporaryUrl() Whichever disk holds the file No

The copy on first read is called promotion. It is a copy, not a move. After my first get() the file existed on both disks. It is also once: the adapter checks primary again after reading fallback, and if another request promoted the file in the meantime it discards what it read and uses the primary copy.

The row that surprised me is delete(). The Laravel News write-up from the 13.26 release says deletes only touch the primary, so a delete() followed by exists() could return true. On 13.32 that is not what happens. ReadThroughFilesystemAdapter::delete() removes the fallback copy first, then the primary one, and my test confirmed the file was gone from both. Aaron Francis's engineering post on laravel.com describes the same both-disks behaviour. Trust the source over the summary, and check your own version.

View the interactive component on hafiz.dev

Two config options matter. 'copy' => false turns promotion off, so the disk becomes a pure two-disk reader. That is useful for a staging environment pointed at production's old bucket, where you want reads to work but must not write anything. And 'throw_on_promotion_failure' => true makes a failed copy fail the read as UnableToReadFile, instead of the default where the read succeeds and the promotion quietly retries next time. Default is right for production, strict is right for tests.

The runbook

Seven steps. The first three take an afternoon, the fourth runs in the background for as long as it needs, and the last three are a deploy each.

1. Create the destination disk and prove it works

Add the s3 disk (or R2, or anything S3-compatible via AWS_ENDPOINT) and write one file to it from Tinker on production. Not staging. Credentials, bucket policy and region mistakes all surface here, where they cost nothing.

Storage::disk('s3')->put('migration-check.txt', now()->toIso8601String());
Storage::disk('s3')->get('migration-check.txt');
Storage::disk('s3')->delete('migration-check.txt');
Enter fullscreen mode Exit fullscreen mode

Keep the same paths on both disks. The read-through driver looks up the identical path on fallback, so if your old disk has avatars/42.jpg the new one must too. If you also want to reorganise paths, do it as a second migration with scoped disks later, not now.

2. Rename, insert, deploy

This is the zero-downtime trick. Your application code refers to a disk by name, usually local through FILESYSTEM_DISK or an explicit Storage::disk('uploads'). Do not change the code. Rename the old disk config to local-legacy, and give the read-through disk the name your code already uses.

'uploads' => [
    'driver' => 'read-through',
    'primary' => 's3',
    'fallback' => 'local-legacy',
],
Enter fullscreen mode Exit fullscreen mode

Deploy. From this request onwards new uploads land in S3, old files are still served from local, and nothing in app/ changed. If you use the public disk with php artisan storage:link, the same rename applies to public, with one caveat about URLs I will get to below.

3. Let traffic promote the hot set

Do nothing for a day or a week. Every file a user actually opens gets copied to S3 on its first read and served from S3 after that. In my test I seeded 2,000 files on the old disk and simulated traffic by reading 300 of them through the read-through disk. Afterwards S3 (well, my stand-in disk) held exactly those 300 and the old disk still held all 2,000.

Watch two things during this phase. Memory, because get() loads the whole file into a string before promoting it. For anything large, your code should already be using readStream(), which promotes through a php://temp buffer that spills to disk above 2 MiB. And latency, because the first read of a cold file now includes a download from old and an upload to new. For a 5 MB PDF that is noticeable. For avatars nobody will see it.

4. Sweep the cold tail

Traffic never touches everything. The files nobody has opened in a year are still on the old disk, and they need moving before you can retire it. This is where 13.32's moveToDisk() earns its place:

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Number;
use League\Flysystem\StorageAttributes;

class SweepStorage extends Command
{
    protected $signature = 'storage:sweep {from} {to} {--prefix=} {--dry-run}';

    protected $description = 'Move every file left on the old disk onto the new one, skipping anything already there';

    public function handle(): int
    {
        $from = Storage::disk($this->argument('from'));
        $to = Storage::disk($this->argument('to'));
        $moved = $skipped = $failed = $bytes = 0;

        // listContents() is lazy: S3 pages through the bucket instead of
        // loading every key into one array the way allFiles() does.
        $listing = $from->getDriver()
            ->listContents($this->option('prefix') ?? '', deep: true)
            ->filter(fn (StorageAttributes $item) => $item->isFile());

        foreach ($listing as $item) {
            $path = $item->path();

            if ($to->exists($path)) {
                $skipped++;  // promoted already, or uploaded after cutover

                continue;
            }

            if ($this->option('dry-run')) {
                $moved++;
                $bytes += $item->fileSize() ?? 0;

                continue;
            }

            if ($from->moveToDisk($to, $path)) {
                $moved++;
                $bytes += $item->fileSize() ?? 0;
            } else {
                $failed++;
                $this->warn("failed: $path");
            }
        }

        $this->info(sprintf(
            '%s %d files (%s), skipped %d already on %s, %d failed',
            $this->option('dry-run') ? 'Would move' : 'Moved',
            $moved,
            Number::fileSize($bytes),
            $skipped,
            $this->argument('to'),
            $failed,
        ));

        return $failed === 0 ? self::SUCCESS : self::FAILURE;
    }
}
Enter fullscreen mode Exit fullscreen mode

Three decisions in there are worth defending.

listContents() on the Flysystem driver instead of allFiles(). allFiles() returns an array, and on a bucket with a million keys that is a million strings in PHP memory before you move anything. listContents(deep: true) returns a DirectoryListing that iterates lazily, so S3 pages through the bucket 1,000 keys at a time.

The exists() check on the destination before every move. It costs one HEAD request per file, and it is the line that makes the sweep safe to run while the app is live. A file already on primary is either one that traffic promoted or one that was uploaded after cutover. Either way the primary copy is the truth, and the sweep must not overwrite it with a stale fallback version.

moveToDisk() rather than copyToDisk(). Under the hood it is copyToDisk() followed by delete() on the source, streamed, so the old disk empties as the sweep progresses and you can watch the numbers converge. The method also accepts a disk instance instead of a name since 13.32, which is why $to can be passed straight in.

Here is the command against my 2,000-file test, after traffic had promoted 300:

$ php artisan storage:sweep local-legacy s3 --prefix=avatars --dry-run
Would move 1700 files (3 MB), skipped 300 already on s3, 0 failed

$ php artisan storage:sweep local-legacy s3 --prefix=avatars
Moved 1700 files (3 MB), skipped 300 already on s3, 0 failed

$ php artisan storage:sweep local-legacy s3 --prefix=avatars
Moved 0 files (0 B), skipped 300 already on s3, 0 failed
Enter fullscreen mode Exit fullscreen mode

The dry run first, always. Then the real run. Then the run that proves nothing is left to move. The second zero is the number you want before step 6.

Against real S3 the sweep is bounded by network, not PHP, so run it under nohup or as a queued job per prefix, with a generous timeout. The queue setup from the 10,000-jobs post applies unchanged: one job per top-level directory, low concurrency, and retries that are safe because the exists() check makes every move idempotent.

5. Verify

Counts are the weak signal. allFiles() on both disks and compare lengths, or better, a lazy listing of the old disk that checks size() on the new one for every path. For anything where corruption would matter, compare checksums on a sample. The laravel.com post is honest that object counts alone are not proof, and I agree.

Then wait. A week of the read-through disk with an empty old disk behind it costs you nothing and tells you whether any code path was still writing to local-legacy directly.

6. Cut over

Point the disk name straight at S3 and drop the read-through config:

'uploads' => [
    'driver' => 's3',
    // ...
],
Enter fullscreen mode Exit fullscreen mode

Deploy. Same trick as step 2: the application code never changed, only what the name resolves to.

7. Retire the old disk

Not before step 6, and not the moment after. The old disk is your rollback for as long as it exists. When you do retire it, delete it wholesale rather than file by file, and here is why.

After my sweep the old disk was not empty. It still held 300 files. Those were the ones traffic had promoted in step 3, because promotion copies and never deletes, and the sweep skipped them because they already existed on the new disk. That is the correct behaviour. It means the old disk is a complete, untouched fallback right up until you decide it is not. It also means "the sweep reported 0 moved" and "the old disk is empty" are different statements. Check the one you mean.

The gotchas I verified

URLs follow the file. Storage::disk('uploads')->url($path) asks whichever disk currently holds the file, so during the migration a page can render one avatar as /storage/avatars/1.jpg and the next as https://bucket.s3.amazonaws.com/avatars/2.jpg. For private files behind a controller that is invisible. For the public disk it is not, and it gets worse: files served straight from /storage/ through the symlink never pass through PHP, so they never promote. If your public uploads are served by nginx, run the sweep for that prefix in step 3, not step 4, so the URLs flip once.

Writes to the old disk after promotion are invisible. I promoted a file, then wrote a new version straight to the old disk. The read-through disk kept returning the old content, because primary had it and primary wins. Nothing should write to the fallback after cutover. If a cron job or a second app still does, find it before step 2.

Overwrites race. If your app overwrites paths in place (a profile.jpg that gets replaced), a put() can land between the promotion's existence check and its write, and the promotion then overwrites the fresh upload with the stale fallback bytes. Immutable, versioned filenames avoid it entirely. If you cannot change the naming, pause overwrites during the migration or sweep those prefixes first.

Deletes need delete permission on the old disk. Because delete() hits fallback first, a read-only credential on the old bucket fails the whole delete, and the primary copy survives. Either give the migration credential delete rights, or defer deletions until the fallback is gone.

Metadata does not travel. Promotion goes through Flysystem's generic write, so cache headers, content disposition and custom S3 metadata come from the new disk's defaults, not from the old object. If you serve files directly from S3 with tuned headers, audit them after the sweep.

Egress costs money once. Moving from local to S3 costs you S3 PUT requests and nothing else. Moving from S3 to R2 costs AWS transfer-out per gigabyte. The laravel.com post has the current rate table and it is the right place to look, because it will be updated and this post will not.

Why this beats the alternatives

The old way, a maintenance page plus aws s3 sync, still works and is simpler to reason about. It costs you downtime proportional to your data, and every minute of that window is a minute you cannot test whether the new disk actually works under real traffic. When I moved a live SaaS to a new server, the storage delta rode inside the same two-minute window as the database snapshot, because there was no way to move files with the site up. This is that way.

Cloudflare's Sippy does the same on-demand promotion at the R2 layer and catches requests that bypass Laravel entirely. If you are moving to R2 and serve files directly from the bucket, it is the better tool for step 3. The read-through disk wins when your files go through PHP anyway, when the source is a local disk that no external service can read, and when you want the migration visible in your own logs and tests rather than in a Cloudflare dashboard.

FAQ

Which Laravel version do I need for read-through disks?

The read-through driver landed in Laravel 13.26.0 (18 August 2026, framework PR #61140), with the copy => false option in the same release. Fallback-aware move() and copy() came in 13.27.0, visibility handling for fallback-only files in 13.30.0, and copyToDisk() / moveToDisk() in 13.32.0 (15 September 2026). Run 13.32 or later for everything in this post.

Does a read-through disk slow down every request?

Reads that hit primary cost one extra exists() check compared to a plain disk. Reads that miss cost an existence check on both disks, a read from fallback, a second primary check and a write. That happens once per file. Writes, listings and deletes on primary are unchanged. Metadata calls like exists() and size() never trigger a copy.

Can I use it to move between two S3-compatible buckets, or between prefixes?

Yes. Both primary and fallback can be any configured disk, including two S3 disks with different endpoints, or two scoped disks with different prefixes on the same bucket. The one requirement is that both adapters support existence checks and reads, and primary supports writes.

Does moveToDisk stream, or load the file into memory?

It streams. copyToDisk() opens a readStream() on the source and passes it to writeStream() on the destination, and moveToDisk() is that followed by delete() on the source. Memory use stays flat regardless of file size, which is what you want for a sweep over gigabytes.

What if the copy to S3 fails during a promotion?

By default the read still returns the file from the fallback, the file stays where it was, and the next read tries again. Nothing is logged, so a persistently failing promotion is silent. Set throw_on_promotion_failure => true (together with the disk's throw => true) if you would rather see the failure as an UnableToReadFile exception.

What to take away

Two disks, one name, and a sweep that is safe to rerun. The read-through driver moves the files people actually use, moveToDisk() moves the rest, and the exists() check in between is the whole reason you can do it with the site up. Keep the old disk until the second sweep reports zero and a week has passed. Then delete it in one go, promoted copies and all.

Top comments (0)