maennchen/zipstream-php
ZipStream-PHP streams ZIP archives directly to the browser or any PSR-7 stream without writing to disk, quickly creating downloads from files, paths, or S3 buckets. Simple named arguments and a single finish call.
Install with Composer and confirm PHP 8.2+ and a 64-bit build (both are hard requirements in v3.1.2+):
composer require maennchen/zipstream-php
Read in this order:
ZipStream constructor with outputName + sendHttpHeaders, then addFile / addFileFromPath / finish).@internal notice in the 3.0 upgrade notes: the only public surface you should depend on is ZipStream\ZipStream, ZipStream\CompressionMethod, and ZipStream\Exception\*. Anything under ZipStream\Option\* from v2 no longer exists.First use case worth wiring up — a controller action that streams several files from a disk without buffering:
use ZipStream\ZipStream;
use ZipStream\CompressionMethod;
public function export(Request $request)
{
$zip = new ZipStream(
outputName: 'export.zip',
sendHttpHeaders: true,
defaultCompressionMethod: CompressionMethod::DEFLATE,
);
foreach (Invoice::where('user_id', $request->user()->id)->cursor() as $invoice) {
$zip->addFile(
fileName: "invoices/{$invoice->number}.pdf",
data: $invoice->pdfBytes(),
);
}
$zip->finish();
}
Return null (or nothing) from the controller — the library writes headers and body directly. Do not wrap it in response()->download() or Response::stream().
Streaming from a Laravel disk (local, S3, Flysystem). Open a PSR-7 stream via the disk adapter and hand it to addFileFromPsr7Stream. This is the pattern the 3.2.2 S3 example demonstrates, and it avoids loading file contents into memory:
$disk = Storage::disk('s3');
$zip->addFileFromPsr7Stream(
fileName: 'report.pdf',
stream: $disk->readStream('reports/2026/report.pdf'),
);
From a seekable path on the local disk, addFileFromPath is faster and avoids double-buffering:
$zip->addFileFromPath(
fileName: 'invoice.pdf',
path: storage_path('app/invoices/'.$id.'.pdf'),
);
Streaming into a queue job or a second destination. Since 3.2.0 you can pass a CallbackStreamWrapper instead of the default output stream — useful to simultaneously write to S3 and send the response, or to write from a queued job without touching HTTP:
use ZipStream\ZipStream;
use ZipStream\Stream\CallbackStreamWrapper;
$zip = new ZipStream(
outputStream: CallbackStreamWrapper::open(function (string $data) use ($s3) {
$s3->write($data); // or fwrite to a handle
}),
sendHttpHeaders: false,
);
Testing. Set sendHttpHeaders: false and pass a php://temp or a custom stream as outputStream. Capture the bytes, then ZipArchive::open() them in the assertion. The library's own test suite follows this pattern.
Choosing compression per file. The archive-level defaultCompressionMethod and defaultDeflateLevel can be overridden per addFile* call. Deflate is a good default for text/PDF; use CompressionMethod::STORE for already-compressed assets (JPEG, ZIP, MP4) to avoid wasted CPU. The removed largeFileSize / largeFileMethod options in v3 mean you implement the size-based switch yourself:
$method = $size > 10_000_000 ? CompressionMethod::STORE : CompressionMethod::DEFLATE;
File naming and directories. Pass forward-slash paths; the library handles directory entries. Prefer explicit lastModificationDateTime (a DateTimeInterface) when the source mtime is meaningful, otherwise files default to "now" and archives aren't byte-reproducible between runs.
Calculating size before streaming. Since 3.1.0 there's a ZipStream::calculateSize() helper — useful if you want to set Content-Length (via a ZipStream in simulation mode) before committing to the response. Don't call it against a live stream; it's a dry-run operation.
finish(). The central directory is written at the end. Forgetting it produces a truncated, invalid ZIP with no error. Put it in a try/finally if any per-file work can throw.dump() before streaming corrupts the ZIP because sendHttpHeaders: true fires immediately in the constructor. If you're unsure, use ob_end_clean() early or switch to sendHttpHeaders: false and set headers yourself.ZipStream; after that, fail loudly (log, abort the connection) rather than trying to send a response.defaultEnableZeroHeader is true since v3 (it was false in v2). This uses data descriptors and omits sizes from local headers — correct for streaming, but some very old unzip tools dislike it. Set defaultEnableZeroHeader: false on individual files if you know maxSize and need maximum compatibility.maxSize or the aggregate exceeds 4 GB. If you're setting maxSize manually, under-reporting it can force Zip64 unnecessarily or, worse, break the directory; the 3.2.2 release fixed a related 4 GB offset bug, so pin to ^3.2.2 if you archive anything near that boundary.method → compressionMethod, time → lastModificationDateTime, size → maxSize. Old Option-class calls (ZipStream\Option\Archive, ZipStream\Option\File) fail with "class not found" — a common upgrade trap on older codebases.callable to Closure. If you previously passed [$this, 'setHeaders'], wrap it.Content-Length-based download managers.@internal markers mean extending internals is unsupported. If you need behaviour that isn't exposed (custom header formats, per-file post-processing), your option is CallbackStreamWrapper — intercept the byte stream and transform it — rather than subclassing internals.$zip->addFile(fileName: ..., data: file_get_contents(...)) in a loop over large files; you'll load everything into PHP memory. Use addFileFromPath / addFileFromPsr7Stream, or chunk data yourself.How can I help you explore Laravel packages today?