Skip to content

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 ​

php
// 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 ​

php
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 ​

php
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_sizeCeiling
5 MB (default)~50 GB
16 MB~160 GB
64 MB~640 GB
512 MB~5 TB, the maximum object size
php
$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_sizeBytes per part, see above
mup_thresholdSize 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
concurrencyParts uploaded in parallel. The archive is produced in order, so this rarely helps
paramsPassed to every S3 command: ['ServerSideEncryption' => 'AES256'], ['StorageClass' => 'STANDARD_IA'], tags, metadata
add_content_md5Adds a Content-MD5 header per part
before_uploadCalled with each command before it is sent
php
$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:

  1. The multipart upload is aborted, so no parts are left billed and invisible in a listing.
  2. The key is deleted, but only if this call created it - an object that was already there survives.
  3. 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:

ServiceHow we know
Amazon S3The reference implementation
MinIOThis package's own S3 test suite runs against it, multipart included
OVHcloud Object StorageConfirmed in production by the maintainers
Cloudflare R2Documented by the provider
DigitalOcean SpacesDocumented by the provider
Scaleway Object StorageDocumented by the provider
Backblaze B2, through its S3 APIDocumented by the provider
WasabiDocumented 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:

php
// The worker, not just the job
php artisan queue:work --timeout=3600

retry_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.