Last year I moved a client’s media bucket from one provider to a cheaper one, and the plan was the one everybody uses. Run rclone overnight. Flip the env var in the morning. Hope nothing gets uploaded during the sync.
It mostly worked. “Mostly” turned out to mean about forty files that landed in the old bucket after rclone had already walked past that prefix. We found them three weeks later when a customer emailed about a broken invoice PDF. I spent an afternoon writing a reconciliation script that should never have needed to exist, and I’ve been quietly paranoid about storage cutovers ever since.
Laravel 13 ships a driver that removes most of that dance. It’s called read-through, and the idea fits in a sentence: stack two disks behind one disk name, send writes to the new one, read from the new one, fall back to the old one when the file isn’t there yet, and copy it across on the way back. Laravel calls that copy a “promotion”.
I’ve spent the last few days with the Laravel engineering post and the filesystem docs, and I like this a lot. I also think there are two or three places where people will adopt it casually and get surprised. So this is half explainer, half list of things I’d check first.
The old way, and the new config
Here’s roughly what I used to do. Two disks, and application code that had to know about both:
// config/filesystems.php
'disks' => [
'r2' => [
'driver' => 's3',
'key' => env('R2_ACCESS_KEY_ID'),
// ...
],
'legacy-s3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
// ...
],
],
// app/Support/Media.php - the class I have now written at three different jobs
public function read(string $path): ?string
{
if (Storage::disk('r2')->exists($path)) {
return Storage::disk('r2')->get($path);
}
$contents = Storage::disk('legacy-s3')->get($path);
if ($contents !== null) {
Storage::disk('r2')->put($path, $contents);
}
return $contents;
}
That helper is fine right up until somebody calls Storage::disk('r2')->get() directly. Or a package does. Or a queued job written before the migration does. Then you’re back to a broken PDF three weeks later.
The 13.x version is a disk instead of a helper:
'assets' => [
'driver' => 'read-through',
'primary' => 'r2',
'fallback' => 'legacy-s3',
],
Point FILESYSTEM_DISK=assets at it and every call site gets the behaviour, including the call sites you forgot about. primary and fallback accept configured disk names or inline config arrays, so you can compose S3 to R2, local to object storage, or one scoped prefix to another inside a single bucket.
That last case is the one I didn’t expect to care about and now think is the best reason to reach for this. If you’re reorganising keys rather than changing provider, two scoped disks with different prefixes give you a live rename with no migration script at all.
The read path is more careful than mine was
My helper up there has a race in it. Two requests hit a cold file at the same time, both miss primary, both read fallback, both write. Best case you pay for the work twice. Worst case one of them clobbers a fresher object.
Laravel checks primary a second time after the fallback read and before the write. If another request populated primary in the meantime, it discards the bytes it just fetched and reads primary instead. That’s not a lock, and the docs say so plainly: a concurrent write can still land between the final check and the write. Immutable or versioned keys dodge the problem completely. If your app overwrites paths in place, think about that before you flip the switch.
Promotion also happens exactly once per path. After a successful copy, later changes to the fallback copy are invisible. Which is correct behaviour, and also the sort of thing that makes staging quietly disagree with production for a week until somebody works out why.
Where promotion quietly doesn’t happen
This is the bit I’d put on a sticky note.
Storage::url($path) finds whichever disk currently holds the file and asks that disk to generate the URL. Temporary download URLs behave the same way. The file doesn’t move. The browser then fetches the bytes straight from the provider, so that request never touches your PHP process at all.
Which means: if your app serves media the way most apps serve media, by handing out direct bucket URLs, traffic-driven promotion will barely promote anything. You’ll watch the numbers, see almost no movement, and conclude the driver is broken. It isn’t. Your reads just aren’t going through Laravel.
Apps that stream files through a controller will watch the working set migrate itself. Apps that hand out signed URLs won’t. Work out which one you are before you plan a timeline, because the answer changes this from “mostly automatic” to “you still need the bulk copy, this just softens it”.
Deletes, and the ghost you’d otherwise create
Deleting through the composite disk removes the path from both stores, fallback first, then primary. The ordering is the point. Delete only from primary and the next read promotes the file back from fallback, and now you have a file your users deleted reappearing on your CDN.
There’s a footgun attached to this. The fallback delete needs a credential with delete permission. Teams doing a careful migration often make the source bucket read only, precisely so they can roll back. Do that and the fallback delete fails, and that failure also stops the primary delete from running. If you want immutability and working deletes at the same time, you need a tombstone table or a deferred delete queue. Decide which before the window opens, not during it.
Reads pull the whole object into memory
get() loads the entire object into a PHP string before promoting it. For a bucket of avatars, fine. For a bucket of video files, that’s a memory limit waiting to happen on some random Tuesday.
readStream() is the better call during a migration. On a fallback hit, Laravel copies the source stream into php://temp, writes that stream to primary, rewinds it, and hands it back. PHP keeps php://temp in memory until it exceeds 2 MiB and then spills to a file in the system temp directory, per the stream wrapper docs. You’re trading PHP memory for temp disk, which is usually the trade you want. Either way, that first request pays for a download from fallback plus an upload to primary, so somebody gets a slow response. Size your timeouts for it, or move the big objects in the background before they ever reach the request path.
One more default worth knowing: promotion is best effort. If the fallback read succeeds and the primary write fails, you get your file and no error at all. Set throw_on_promotion_failure to true if you’d rather hear about it, and remember the disk’s own throw option also has to be true, otherwise get() catches the exception and returns null anyway.
What it costs while it’s running
Worth doing this arithmetic before you turn it on rather than after.
In an S3 to R2 setup, the first content read of a cold object can cost up to two R2 existence checks, one S3 GET, and one R2 write. Cloudflare bills existence checks as Class B operations and writes as Class A. Their pricing page has the current rates and the free tiers are generous enough that most small apps won’t notice. A bucket with millions of keys and a high miss rate is a different conversation.
The AWS side is the one that actually stings, because data transfer out of S3 is the real bill. Read-through doesn’t shrink that total if every object eventually moves. What it does is spread the cost over weeks and let real traffic pick the order, so the working set moves first and you aren’t paying today to copy files nobody has requested since 2021.
It isn’t finished when the traffic goes quiet
Traffic-driven promotion moves the hot files. The cold tail sits on fallback indefinitely, and by count it’s usually most of the bucket.
The finish looks like this. Point the app at the composite disk and let it run. Enumerate the remaining keys through the fallback disk and dispatch background copy jobs that skip anything already present on primary. Track transferred keys, skipped keys and failures, because you’ll want those numbers when somebody asks whether it’s safe to drop the old bucket. Verify by comparing key sets rather than object counts. Then point the app straight at primary, remove the read-through disk, and revoke the fallback credentials.
A practical note from the docs that I would definitely have tripped over: allFiles() returns an array, so on a bucket with millions of keys it will happily eat your worker’s memory. Use paginated provider listings for the enumeration step.
Provider specific metadata doesn’t come along for the ride either. Cache headers, content disposition, storage class, custom metadata, the original modification timestamp. Promotion writes through Flysystem’s generic contract, so the destination disk applies its own upload defaults. If you serve objects directly from storage and depend on cache headers, audit the delivery path before you retire the source bucket.
What I’d actually do this week
If you have a storage migration parked in a backlog somewhere because nobody wanted to own the maintenance window, spend twenty minutes on this instead of another planning meeting:
- Work out what share of your reads go through PHP versus direct bucket URLs. That one number tells you whether read-through does most of the work or only some of it.
- Check whether your app overwrites file paths in place. If it does, plan to freeze those writes during the window.
- Grep for direct
Storage::disk('old-disk')calls. Those bypass the composite disk, and they’re the ones that will bite you.
Then wire up the composite disk in staging against a copy of production keys and watch what actually promotes.
None of this is exotic. It’s the read-through cache pattern, the same shape I reach for when chasing query problems in Laravel, applied to object storage and finally living in the framework instead of in a helper class each of us keeps rewriting. Most of the infrastructure work I take on for clients turns out to be this kind of unglamorous cutover, and I’ve written more about that side of the job in my work. The driver won’t save you from thinking about your data. It will save you from the reconciliation script.