Imagine a SaaS platform with five million customer files stored in Amazon S3.
Users continue uploading invoices, profile images, reports, and videos every minute. Meanwhile, the development team wants to move those files to Cloudflare R2.
A traditional migration creates an uncomfortable choice: stop new uploads during the transfer, build temporary dual-storage logic, or risk files being lost between the old and new systems.
Laravel 13 now offers another approach.
On August 18, 2026, Laravel introduced a read-through filesystem driver that allows an application to use two storage locations as one logical disk during migration. New files go to the destination immediately, while older files remain accessible from the source and can move when customers request them.
The result is not a one-click migration. It is a safer way to separate application cutover from bulk data transfers.
Before this feature, development teams generally had to copy every object before changing the application’s storage configuration. Another option was to write custom fallback logic that checked the new bucket and then searched the old one when a file was missing.
Both approaches increase operational risk.
| Migration concern | Traditional cutover | Laravel 13 read-through |
| New uploads | May require a write freeze or custom dual writes | Go directly to the destination |
| Existing files | Usually copied before cutover | Stay accessible from the source |
| Downtime | A maintenance window may be required | Storage requests can continue |
| Popular files | Move with the entire dataset | Can move when first requested |
| Cold files | Copied even if rarely used | Can move later in a bulk transfer |
| Rollback | Difficult after the final switch | Source remains available until retired |
| Migration logic | Often application-specific | Uses Laravel’s filesystem abstraction |
The important difference is timing. The application can begin using the destination before every legacy object has been transferred.
Laravel’s official pull request shows a new read-through driver with a primary and fallback disk.
In an S3-to-R2 migration, the existing S3 bucket becomes the fallback, while R2 becomes the primary destination.
A simplified config/filesystems.php setup could look like this:
‘disks’ => [
‘legacy-s3’ => [
‘driver’ => ‘s3’,
‘key’ => env(‘AWS_ACCESS_KEY_ID’),
‘secret’ => env(‘AWS_SECRET_ACCESS_KEY’),
‘region’ => env(‘AWS_DEFAULT_REGION’),
‘bucket’ => env(‘AWS_BUCKET’),
],
‘r2’ => [
‘driver’ => ‘s3’,
‘key’ => env(‘R2_ACCESS_KEY_ID’),
‘secret’ => env(‘R2_SECRET_ACCESS_KEY’),
‘region’ => ‘auto’,
‘bucket’ => env(‘R2_BUCKET’),
‘endpoint’ => env(‘R2_ENDPOINT’),
],
‘assets’ => [
‘driver’ => ‘read-through’,
‘primary’ => ‘r2’,
‘fallback’ => ‘legacy-s3’,
],
],
The application can then use the composite disk as its default:
FILESYSTEM_DISK=assets
From this point, new writes go to R2. Existing objects can still be read from S3.
The configuration is short, but teams should test it in a non-production environment and confirm that both filesystem adapters support every operation used by the application.
What Happens When Laravel Reads an Old File?
The application code remains familiar:
use Illuminate\Support\Facades\Storage;
$content = Storage::disk(‘assets’)
->get(‘avatars/customer-42.jpg’);
Behind this one call, Laravel performs a multi-stage process.
First, it checks whether the file exists on the primary R2 disk. If found, Laravel returns it normally.
If the file is missing, Laravel reads it from the fallback S3 bucket. Before copying it, the framework checks the primary disk again. This reduces duplicate promotions and helps prevent an older source object from overwriting a file created by another request.
If the destination is still empty, Laravel copies the file to R2 and returns it. Future requests use the R2 version.
Laravel calls this process promotion.
Application request
↓
Check primary storage
↓
File missing?
↓
Read from fallback
↓
Check primary again
↓
Copy to primary and return
The second check reduces race conditions, but it does not make the check-and-write sequence atomic. Applications that frequently overwrite the same path should use versioned object keys or coordinate writes during the transition.
The read-through driver intentionally routes operations differently.
| Laravel operation | Storage behaviour | Does it promote the file? |
| get(), read(), readStream() | Primary, then fallback | Yes, by default |
| exists(), size(), mimeType() | Primary, then fallback | No |
| put(), writeStream() | Primary only | Not applicable |
| Directory listing | Primary only | No |
| delete() | Fallback, then primary | No |
| move() | Primary, then fallback | May require an initial promoted read |
| copy() | Primary | Operates on destination state |
| Temporary download URL | Disk currently holding the object | No |
| Temporary upload URL | Primary | Not applicable |
Delete behaviour solves a subtle problem.
If an object were deleted only from R2 but remained in S3, a future read could promote it again. Laravel therefore deletes from the fallback before deleting from the primary.
That also means the fallback credentials need delete permission. A company keeping the source bucket read-only will need a separate deletion record or deferred cleanup process.
Laravel’s official engineering article published the following public rates as of August 2026:
| Item | Public rate cited by Laravel |
| R2 Standard storage | $0.015 per GB-month |
| R2 Class A operations | $4.50 per million; first one million monthly operations free |
| R2 Class B operations | $0.36 per million; first 10 million monthly operations free |
| R2 internet egress | Free |
| AWS internet data transfer out | First 100 GB monthly free; common US paid tiers begin at $0.09 per GB |
| S3 Standard GET requests in US East | $0.0004 per 1,000 requests |
Consider an illustrative migration involving 5,000 GB and one million objects. Assume all data moves within one month, each object is read once from S3, and the cited common AWS transfer rate applies.
| Illustrative cost component | Calculation | Estimated amount |
| AWS data transfer | 4,900 chargeable GB × $0.09 | $441.00 |
| One million S3 GET requests | 1,000 × $0.0004 | $0.40 |
| One million R2 writes | Within first one million Class A operations | $0.00 |
| Two million R2 existence checks | Within first 10 million Class B operations | $0.00 |
| First month of R2 storage | 5,000 GB × $0.015 | $75.00 |
| Illustrative first-month total | Transfer + requests + storage | $516.40 |
This is not a quotation or a guaranteed migration cost. Actual charges depend on binary versus decimal storage measurement, AWS region, account-level free usage, storage class, retries, multipart operations, object count, negotiated pricing, and how much data moves during the billing period.
The calculation is useful because it shows that data transfer may cost substantially more than the storage operations themselves.
Read-through migration does not necessarily reduce the total bytes that eventually move. Its advantage is that the active working set can migrate first, while the business postpones the cost and effort of moving rarely accessed objects.
Using get() loads the complete object into a PHP string before promotion.
$file = Storage::disk(‘assets’)->get($path);
Memory use therefore grows with the size of the object. That may be acceptable for thumbnails or documents, but it is risky for large videos, backups, or archives.
Laravel supports streamed reads:
$stream = Storage::disk(‘assets’)->readStream($path);
if ($stream !== null) {
// Process or return the stream safely.
}
According to Laravel’s official explanation, the stream uses php://temp. PHP keeps the content in memory until it exceeds 2 MiB, then moves it to a temporary file.
Streaming limits PHP memory pressure, but the server still needs enough temporary-disk capacity. The first request also includes the time required to download the object from the source and upload it to the destination.
Large or latency-sensitive files may be better moved through background jobs before users request them.
Promotion is best effort by default.
If Laravel successfully reads an object from S3 but cannot write it to R2, it can still return the source content to the user. The file remains in S3, and a later request can attempt promotion again.
Applications that need the read itself to fail when promotion fails can enable strict behaviour:
‘assets’ => [
‘driver’ => ‘read-through’,
‘primary’ => ‘r2’,
‘fallback’ => ‘legacy-s3’,
‘throw_on_promotion_failure’ => true,
‘throw’ => true,
],
Best-effort mode prioritizes application availability. Strict mode prioritizes immediate visibility of migration failures.
Neither choice is universally correct. A public-media application may prefer to serve the source file even when promotion fails. A compliance-sensitive document system may need the failure reported immediately.
No.
Popular files will move as customers request them. Rarely accessed objects form a “cold tail” and may remain in the fallback bucket indefinitely.
Laravel recommends completing the migration through background jobs. Teams should track transferred bytes, completed keys, skipped keys, retry counts, and failed objects.
Verification should compare more than the number of files. Where the providers support it, teams should compare object keys, sizes, and checksums.
A simple Laravel verification step might use the storage checksum API:
$sourceChecksum = Storage::disk(‘legacy-s3’)
->checksum($path);
$destinationChecksum = Storage::disk(‘r2’)
->checksum($path);
if ($sourceChecksum !== $destinationChecksum) {
report(“Checksum mismatch: {$path}”);
}
Provider checksum behaviour can differ, especially for multipart uploads. The comparison method must therefore be validated against the selected storage providers.
Only after the bulk transfer, verification, and an observation period should the application point directly to the destination and retire the fallback credentials.
Laravel’s new driver reduces cutover risk, but development teams still need to manage concurrency, large-file latency, provider-specific metadata, storage permissions, direct URLs, retries, and billing.
Cache-control headers, content disposition, storage classes, custom metadata, and original modification times may not move through Laravel’s generic write contract. A provider-aware transfer or metadata pass may be necessary.
Direct public URLs also do not trigger promotion. When a browser downloads directly from S3, the request bypasses Laravel’s read-through adapter. Applications relying heavily on direct URLs will need a background migration or an application-controlled download route.
A production storage migration is not only a configuration change. It involves Laravel architecture, cloud permissions, background queues, file integrity, cost modelling, monitoring, rollback planning, and customer-facing performance.
As a Laravel development company, Spaculus Software can help businesses assess existing storage patterns, upgrade Laravel applications, configure incremental migrations, build background-copy workflows, validate migrated data, and monitor the transition before the original storage is retired.
The right approach depends on file volume, object size, traffic distribution, metadata requirements, compliance obligations, and the cost of a failed migration.
Laravel 13 turns object-storage migration from one major cutover into a controlled transition.
That is the real value of the feature.
Teams can redirect new writes, keep old files available, migrate active data first, verify the destination, and retire the source only when the evidence shows that the migration is complete.
Would your team trust one large storage cutover, or prefer to move gradually while both providers remain available?
Best Laravel Development