barracudanetworks/archivestream-php
Install with Composer and check what you actually got before writing code:
composer require barracudanetworks/archivestream-php
composer show barracudanetworks/archivestream-php # version, PHP constraint, psr/http-message constraint
Then read, in this order:
README.md — the usage block is short and is the authoritative API reference; there is essentially no other documentation.src/ — you will find ZipStreamer and TarStreamer classes plus the shared interfaces. The core surface is addFileFromPath(), addFileFromString(), addFileFromStream(), and finalize().composer.json — confirm the PHP version constraint and the psr/http-message requirement, because that is what determines how you wire it into a framework.The key design fact: the streamer is the output. It implements Psr\Http\Message\StreamInterface, so you add entries, call finalize() (which writes the ZIP central directory / TAR trailer), and then consume the stream. In a PSR-7 stack (Slim, Laminas) you pass it straight to withBody(). In Laravel/Symfony you read it in a loop inside a streamed response.
First use case — a download endpoint that zips a handful of records without ever touching disk:
use ZipStreamer\ZipStreamer;
Route::get('/exports/{id}', function (string $id) {
$zip = new ZipStreamer();
$zip->addFileFromString('README.txt', "Export {$id}\n");
$zip->addFileFromPath('report.csv', storage_path("exports/{$id}.csv"));
$zip->finalize();
return response()->stream(function () use ($zip) {
while (! $zip->eof()) {
echo $zip->read(8192);
flush();
}
}, 200, [
'Content-Type' => 'application/zip',
'Content-Disposition' => 'attachment; filename="export.zip"',
'X-Accel-Buffering' => 'no',
]);
}, )->middleware('auth');
That is the "first use case" this package exists for: an archive whose size you do not know and do not want to materialise on disk.
Wrap it in a small service, not in controllers. The streamer has no opinion about your app; put the entry-building logic in something like App\Exports\ZipExportBuilder that returns a finalised streamer. Controllers then only deal with HTTP headers, and tests can build archives without booting HTTP.
Feed it from Laravel's storage layer. Do not download S3 objects to a temp file first:
foreach ($paths as $path) {
$handle = Storage::disk('s3')->readStream($path); // remote read stream
$zip->addFileFromStream(basename($path), $handle);
fclose($handle); // close as soon as it's added
}
This is the main reason to prefer this library over ZipArchive: ZipArchive needs a real file, so a multi-GB S3 archive means multi-GB local disk churn.
Build CSV entries with php://temp. Generate rows incrementally, write to a php://temp handle (which spills to disk past 2 MB), rewind, and hand the handle to addFileFromStream(). Combine with ->lazy()/chunk() on the query so memory stays flat regardless of row count.
Combine with TAR for Unix-facing exports. TarStreamer produces a plain TAR suitable for tar -x on servers or for piping into your own gzip stage. Use ZIP for end users (Windows Explorer, macOS Finder) and TAR when the consumer is a script or CI job.
PSR-7 stacks get a one-liner:
$response = $response->withBody($zip)
->withHeader('Content-Type', 'application/zip')
->withHeader('Content-Disposition', 'attachment; filename="export.zip"');
Test by re-opening the archive, not by asserting bytes. Capture the stream into a php://temp handle, write it out inside the test, and assert with ZipArchive::open() / PharData on the entry list and contents. Byte-level assertions will break on any internal change and prove nothing.
Queue jobs are the wrong shape. Streaming assumes a live request and a client that reads. If an export must be retried, emailed, or cached, produce the file in a job using the same streamer but write it to a storage disk (or a multipart S3 upload) instead of to echo. Keep that distinction explicit in your codebase, because "just move it to a queue" is the first thing someone will try.
finalize() is not optional. Without it the ZIP has no central directory and unzip tools reject it. Finalise before the first read, and make it the last thing your builder does.Content-Length. Never set one; the size is unknown until the last byte. This also means no range requests/resume — do not advertise Accept-Ranges.output_buffering / zlib.output_compression (ob_end_clean() before streaming if needed), nginx proxy_buffering (the X-Accel-Buffering: no header above), Apache mod_deflate on application/zip, and any Laravel response middleware or debug toolbar (Debugbar/Clockwork can inspect and therefore buffer the body — disable them on these routes).set_time_limit(0), and check connection_aborted() between chunks so an abandoned download stops pulling from S3.rewind() will not work and getSize() returns null. Anything that calls __toString() on the stream (some HTTP clients, some middleware) will slurp the entire archive into memory and silently undo the whole benefit. Grep for code paths that stringify response bodies.psr/http-message constraint against what your framework requires (composer why-not is your friend). If you hit friction, maennchen/zipstream-php is the actively maintained equivalent and is a drop-in conceptual replacement for the ZIP half.../, leading /, or NUL bytes produce path traversal on extraction or outright invalid archives. Also deduplicate names — two identical entries make some extractors overwrite silently.unzip on Linux.StreamInterface that counts bytes or adds logging. Subclassing the streamer is possible but couples you to its internals.readStream() and addFileFromStream() leaks a descriptor per file; wrap acquisitions in try/finally.How can I help you explore Laravel packages today?