S3
S3 is both ends of the common case: the entries are objects in a bucket, and the finished archive goes back into one. Both directions stream, so the size of the archive is bounded by nothing but the part limits below.
The disk
// config/filesystems.php
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'endpoint' => env('AWS_ENDPOINT'), // MinIO, R2, Spaces...
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'stream_reads' => true, // see below - this one matters
'throw' => true,
],stream_reads is the setting to get right. Laravel defaults it to false, which makes Flysystem omit @http.stream and leaves Guzzle buffering each whole object in memory before readStream() returns. Every entry then becomes a stall with no bytes sent, which is what trips fastcgi_read_timeout and kills a long download half way. With it on, an object is read as it is written into the archive.
throw only decides what Flysystem itself does. A failure while building the archive reaches you either way - saveToDisk() rethrows the original exception rather than Flysystem's wrapper.
Reading from S3
Zip::fromDisk('s3', 'events/2026/gala/IMG-0001.jpg', 'IMG-0001.jpg')
->fromDiskDirectory('s3', 'events/2026/gala/raw', 'raw')
->toResponse();Requests made before a single byte is streamed:
| Requests | |
|---|---|
fromDisk() | One HEAD per entry, to verify it exists |
fromDiskDirectory() | One listing for the whole prefix, sizes included |
withoutVerification() | None |
withKnownSize() and withContentLength() need an exact size per entry. Rather than a HEAD per file, the package groups the entries that lack one by directory and lists each directory once - so a handpicked archive costs the same listing a whole prefix would.
For an archive of a few hundred objects under one prefix, fromDiskDirectory() is the difference between one request and several hundred. It also hands each entry the exactSize the listing already carried, which is what withKnownSize() and withContentLength() need.
Writing to S3
Zip::fromDiskDirectory('s3', 'events/2026/gala')
->saveToDisk('s3', 'archives/gala.zip');The archive is uploaded while it is built. Nothing is written to local disk, and memory stays flat no matter how large the result is.
Part size and the 50 GB ceiling
S3 accepts at most 10,000 parts per multipart upload, and the SDK defaults to 5 MB parts. That caps an archive at about 50 GB - not because of this package, but because of the part count. Raise part_size for anything bigger:
part_size | Ceiling |
|---|---|
| 5 MB (default) | ~50 GB |
| 16 MB | ~160 GB |
| 64 MB | ~640 GB |
| 512 MB | ~5 TB, the maximum object size |
$zip->saveToDisk('s3', 'archives/huge.zip', ['part_size' => 64 * 1024 * 1024]);A part is held in memory while it uploads, so the part size is also the memory this costs. 64 MB parts are a reasonable trade for a very large archive; 512 MB parts are not, unless the box has the room.
Options
The third argument of saveToDisk() goes to Flysystem, which passes these on to the SDK:
| Option | |
|---|---|
part_size | Bytes per part, see above |
mup_threshold | Size above which multipart is used at all. Defaults to 16 MB, and barely applies here - an archive of unknown size goes multipart as soon as it passes 5 MB |
concurrency | Parts uploaded in parallel. The archive is produced in order, so this rarely helps |
params | Passed to every S3 command: ['ServerSideEncryption' => 'AES256'], ['StorageClass' => 'STANDARD_IA'], tags, metadata |
add_content_md5 | Adds a Content-MD5 header per part |
before_upload | Called with each command before it is sent |
$zip->saveToDisk('s3', 'archives/gala.zip', [
'part_size' => 32 * 1024 * 1024,
'params' => ['StorageClass' => 'STANDARD_IA'],
]);The package wraps before_upload to remember the upload id it may have to abort. A callback of your own still runs.
What a failure leaves behind
Nothing:
- The multipart upload is aborted, so no parts are left billed and invisible in a listing.
- The key is deleted, but only if this call created it - an object that was already there survives.
- The original exception is rethrown, not Flysystem's
UnableToWriteFile, and it reaches you even on a disk configured with'throw' => false.
abort(discard: true) takes the same route deliberately, for a download the user cancelled.
To find out whether the key existed, saveToDisk() makes one HEAD request before writing.
Unknown size, and the rewind
The SDK wants to know how big a body is. An archive that is still being built cannot say, so the SDK reads 5 MB to decide between a single PutObject and a multipart upload, then rewinds. PHP reports every userland stream as seekable, so this cannot be declined - the package keeps the first 6 MB in memory to satisfy that one rewind, and drops it afterwards. It is the only buffering left in the path.
S3-compatible services
Writing an archive of any size needs multipart uploads. These are the services that have them, and how we know:
| Service | How we know |
|---|---|
| Amazon S3 | The reference implementation |
| MinIO | This package's own S3 test suite runs against it, multipart included |
| OVHcloud Object Storage | Confirmed in production by the maintainers |
| Cloudflare R2 | Documented by the provider |
| DigitalOcean Spaces | Documented by the provider |
| Scaleway Object Storage | Documented by the provider |
| Backblaze B2, through its S3 API | Documented by the provider |
| Wasabi | Documented by the provider |
Anything else may well work - the driver is the same - but we have not checked it. If you run this against a service that is not on the list, a pull request adding it is welcome. The bar is having actually written a large archive to it, not having read that it should work.
Without multipart, an upload is capped by whatever the service accepts in a single PutObject, and this package has no way to make that larger.
Connecting to one
Point endpoint at the service, and set use_path_style_endpoint where it addresses buckets by path rather than by subdomain. Check the provider's own documentation for the exact host - several serve each region from its own, and region then has to be the one in that host. A mismatch between the two shows up as a signature error rather than as anything helpful.
The 10,000 parts above is the S3 API limit and is widely matched, but maximum part and object sizes vary - worth a look before planning for a very large archive.
The package's own S3 test suite runs against MinIO - see Testing against S3 with MinIO.
Long uploads
A queue worker is the right place for a large archive. Give it room:
// The worker, not just the job
php artisan queue:work --timeout=3600retry_after in config/queue.php has to be larger than that timeout, or the job is handed to a second worker while the first is still uploading - and two workers writing the same key is a race nobody wins.
Back to the documentation index.