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.
Architecture fit. ZipStream-PHP is a single-purpose, framework-agnostic Composer library that emits ZIP bytes incrementally to an output stream instead of materializing an archive on disk. For a Laravel estate this maps cleanly onto a recurring class of features: bulk export endpoints, media/asset bundles, invoice and statement packs, data-room downloads, and CSV/JSON dumps zipped on the fly. The architectural value is not the ZIP format — ext-zip already does that — it's the removal of the temp-file tier: no scratch disk, no /tmp lifecycle, no cleanup jobs, no disk-full pages, flat memory under concurrency, and a much better time-to-first-byte. The public surface is deliberately tiny in v3 (ZipStream\ZipStream, CompressionMethod, and the Exception* hierarchy; everything else is marked @internal), which is exactly what a TPM wants to see in a dependency that will be called from controllers, jobs, and Artisan commands.
Integration feasibility. Low. One composer require, no service provider, no config file, no migrations, no published assets. It accepts PSR-7 streams, so it composes with Flysystem 3 and the S3 adapter that Laravel already ships, and it can write to php://output for a StreamedResponse or to a custom sink via CallbackStreamWrapper::open() (added in 3.2.0) for fan-out to S3 plus a local copy. The realistic effort is one to two engineer-days for a spike, not a sprint.
Technical risk. Concentrated in operations rather than in the code path:
sendHttpHeaders: true calls header() directly, which will collide with Laravel/Symfony response middleware, CORS, and ETag layers. The integration rule should be sendHttpHeaders: false with Laravel owning the response.defaultEnableZeroHeader flipped to true in 3.0, meaning sizes are written in trailing descriptors. Modern clients are fine; legacy enterprise unzip tooling is the population to test before promising compatibility.Dependents: 0 against 1,907 stars is almost certainly a dataset artifact — verify via Packagist download counts and GitHub network before it enters any business case. The composite score of 32.6 similarly reads as a narrow-scope penalty, not a quality signal.Key questions.
Stack fit. Laravel 10+ on PHP 8.2/8.3, 64-bit, 64-bit CI runner. No conflict with ext-zip; the two can coexist, which matters for a phased migration. It slots naturally behind a StreamedResponse or response()->streamDownload() for synchronous downloads, and behind a queued job writing to S3 via Flysystem for anything heavy. It is not an HTTP layer, a job runner, or a storage abstraction — pairing it with Bus/Horizon and Storage::disk('s3') is the intended composition, not a gap.
Migration path. Replace ZipArchive-to-temp-file call sites in four phases:
ZipExport abstraction (add file from path / from stream / from string, set compression per file) so call sites never touch ZipStream directly — this is the seam that makes the vendor's @internal policy irrelevant to us.unlink calls, and any scheduled cleanup commands as each one moves.CallbackStreamWrapper, then hand the user a signed URL. Retain synchronous streaming only for small, interactive bundles.Compatibility. Pin with a caret constraint on 3.2 (^3.2) and treat majors as planned work — the 2.x → 3.x transition replaced option classes with named arguments across the board, so the abstraction layer above pays for itself at the next major. Confirm PHP_INT_SIZE === 8 and PHP_VERSION_ID >= 80200 in CI and in a health check. Explicitly avoid extending internal classes; if we need behavior the public API doesn't expose, that's an upstream issue, not a subclass.
Sequencing. Library spike and client-compatibility test matrix first (that's the long-lead item, not the code); then nginx/FPM tuning (proxy_buffering off, gzip off for the download route, raised max_execution_time per route); then the pilot; then the abstraction; then bulk migration; then decommissioning of temp-file infrastructure and its monitoring. Instrument the pilot for bytes streamed, duration, disconnect rate, and error rate before touching the third endpoint.
Maintenance. Low, with a predictable shape. The library tracks PHP's own support window, ships frequent small patch releases (3.1.1 → 3.1.2 → 3.2.0 → 3.2.1 → 3.2.2), and its changelog is granular enough to assess exposure quickly. Expect routine dependency bumps in the annual maintenance window and one disruptive major every few years. Budget a half-day per minor, a sprint per major. The internal/public class boundary is a genuine asset here: if our integration only touches ZipStream, CompressionMethod, and exceptions, most releases are invisible to us. The @internal promotion of classes we might be tempted to use is a governance win, not friction — but it does mean any hack against internals becomes a permanent upgrade tax, so the abstraction layer is a maintenance control, not just a design preference.
Support. Community-supported only. GitHub Discussions is the front door, with documentation hosted separately and a sponsorship-based 1:1 channel for escalation. There is no vendor SLO, no security hotline beyond the GitHub advisory flow, and effectively one primary maintainer. For internal tooling and customer-facing exports where a broken ZIP is an annoyance, that is acceptable. If a download failure becomes revenue-affecting, the mitigations are: pin exact patch versions, keep the previous implementation behind a flag for one release cycle, and budget for a sponsored support relationship rather than assuming escalation exists.
Scaling. Two levers dominate. First, compression method per file: JPEGs, MP4s, PDFs, and already-compressed formats should use STORE rather than deflate — this is pure CPU savings with no size cost, and on an asset-bundling endpoint it can be the difference between a comfortable and an oversubscribed worker pool. Second, defaultDeflateLevel: tune down for latency-sensitive interactive downloads, up for background jobs. Memory stays flat regardless of archive size because nothing is buffered whole, so the constraint moves from RAM to worker-seconds and to wall-clock limits. Large exports belong in the queue, not the request cycle — that also gives retries, which the streaming model otherwise lacks.
Failure modes. Design for these explicitly, because most are unrecoverable once the first byte leaves:
ignore_user_abort(false) and check connection_aborted() in long loops.max_execution_time or an upstream proxy timeout produces a valid-looking .zip that won't extract. Per-route timeout configuration is mandatory, and it should be derived from the archive-size estimate, not a global default.proxy_buffering off on download routes, verified in staging.headers already sent — the predictable result of leaving sendHttpHeaders enabled inside Laravel. Make false the enforced default in the abstraction.Ramp-up. Small. The public API is three entry points and a named-argument constructor; a mid-level Laravel developer is productive in a day and fully fluent in a week. The real onboarding cost is operational, not programmatic: teaching the team why the headers must be owned by Laravel, why the download route is exempt from gzip and buffering, why compression method is a per-file decision, and why these endpoints don't get temp files to inspect when something goes wrong. Put that in the runbook, not the README, and pair it with a dashboard on bytes streamed and incomplete-download rate — because the failure mode here is silent, and a truncated ZIP looks like a support ticket about "the file is broken," not an alert.
How can I help you explore Laravel packages today?