Skip to content
Back to blog
LaravelPHPCloud

Switch buckets without downtime, with read-through disks

Laravel 13.26 ships a disk driver that reads from two places and moves files across as it goes. Migrating from S3 to R2 stops being an all-nighter.

Ismael Catala4 min read

Moving an application's files from one bucket to another is the kind of job nobody volunteers for. The code is easy; the ordering is what hurts. You copy everything, and while you copy people keep uploading. You flip the config, and find out three folders were missed. And somewhere in the middle, someone asks for a 2019 invoice that got left behind.

The classic way out is writing to both disks for a few weeks, with an if scattered across half the codebase that nobody later dares to remove.

Laravel 13.26 offers another way: a disk driver that reads from two places and moves files across on its own, as they get requested.

How you configure it

It's just another disk in config/filesystems.php, except it points at two places instead of one:

'assets' => [
    'driver' => 'read-through',
    'primary' => 's3',
    'fallback' => 'legacy-s3',
],

primary is where you want to end up. fallback is the old bucket that still holds things. Your code doesn't change: you keep calling Storage::disk('assets') exactly as before.

When someone requests a file, Laravel checks s3 first. If it isn't there but it is on legacy-s3, it serves it from the old one and copies it to the new one for next time. Real traffic performs the migration, file by file, with no script and no maintenance window.

What goes where

Worth getting clear before you touch production, because not everything behaves the same way:

  • Reads: primary first; if it's missing, the fallback, and it gets copied to the primary.
  • Writes: primary only. New files are born in the right place.
  • Directory listings: primary only.
  • Existence and metadata checks: either disk, and nothing is copied.

That last one is a thoughtful detail: you don't want checking whether a file exists to trigger a transfer between buckets.

The failure that doesn't break the request

What if the copy to the new disk fails? Maybe the bucket is full, or the write credentials aren't what you thought they were.

By default Laravel stays quiet and serves the file anyway. The read succeeds, which is what the person waiting cares about; that file simply stays on the old bucket and gets retried next time.

While you're actually migrating you'll want the opposite, because a silent failure means the move isn't progressing and you don't know it:

'assets' => [
    'driver' => 'read-through',
    'primary' => 's3',
    'fallback' => 'legacy-s3',
    'throw_on_promotion_failure' => true,
],

My advice: turn it on from the start, watch it for a few days with the errors in plain sight, and decide later whether you prefer the silence.

Where the catch is

This is not a complete migration, and it's worth saying plainly: only what gets requested moves. The files nobody touches — 90% of any bucket with a few years on it — stay where they are forever. A read-through disk removes the urgency and the downtime, but you'll still need a final sweep to copy the rest before you can switch the old bucket off.

And mind the listings. If anywhere in your code you walk directories with files() or allFiles(), those calls only see the primary: anything still on the old bucket won't show up. If you have an admin panel listing a folder, or a command walking files to build a report, check it before you roll this out. It's the kind of bug that breaks nothing and surfaces three weeks later, when someone asks why documents are missing from a screen.

And for a homelab

The obvious case is S3 to R2 to stop paying egress, but it works just as well with a MinIO at home: your own storage becomes the primary, the paid bucket the fallback, and the bill drops on its own as traffic pulls files onto your server. The nice part is that if the box at home goes down one day, the files that hadn't migrated yet still answer from where they always were.


This landed in Laravel 13.26, which brings more with it — #[DebounceFor] on queued listeners and Queue::forward(), both deserving their own post. The behaviour details for this driver live in the File Storage documentation.