Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Zipper Laravel Package

aerni/zipper

Aerni/Zipper is a Laravel package for creating, extracting, and managing ZIP archives through a simple, expressive API. It wraps PHP’s ZipArchive to handle files and folders with minimal configuration.

View on GitHub
Deep Wiki
Context7

Getting Started

Zipper is a single-purpose Statamic addon: it exposes an endpoint (route + tag) that streams a zip of assets from a container so you don't have to hand-roll ZipArchive in a controller. That's the whole value proposition — it's glue, not a framework. Treat it as a small utility you'd otherwise write in 40 lines, and evaluate it the same way.

Version note before you start: current is v2.5.1. The 1.x line is behind you now, so ignore any v1-era snippet you find in blog posts — check composer.json constraints and the tag signature against the version you actually install. v2.5.1 is a bugfix release (see Gotchas: cached downloads), so pin ^2.5.1 at minimum, not ^2.0.

Install and confirm what it actually registers. There is no meaningful documentation surface for a package this size, so the source is the documentation:

composer require aerni/zipper:^2.5.1
php artisan vendor:publish --tag=zipper-config   # if a config stub ships

Then read, in this order, before writing a line of app code:

  1. README.md — the only place the tag/route signature is documented.
  2. src/ServiceProvider.php — what routes, tags, and config keys are registered, and under what middleware group.
  3. routes/web.php (or wherever Route:: calls live) — the exact URI, HTTP verbs, and whether it's public by default.
  4. config/zipper.php — container allowlist, filename, recursion, cache/TTL keys (these matter more than you'd think — see Implementation Patterns).

Those four files tell you everything the package does. Budget fifteen minutes; you'll spend less time than debugging a guessed tag signature.

Requirements to verify up front: the zip PHP extension (php -m | grep zip), a Statamic version the addon actually supports (check composer.json constraints against your statamic/cms version), and — critically — that your asset containers use a local filesystem driver. Remote disks are where this class of package usually falls over (see Gotchas).

First use case: a "Download all" button on an asset container's listing. In the Control Panel, add a link/button that points at the addon's route with the container handle and (optionally) a folder path, and gate it behind the same permission the CP uses. Something in the shape of:

<a href="{{ zipper url="/!/zipper/assets/download" ?? '' }}" class="btn-primary">
    Download all
</a>

Verify the real tag/route name from step 2 — don't copy the above. The point is the shape: container + optional path → zip download, with no server-side state.

If your need is "one button, one container, whole folder, all images" — you're done in an afternoon. If your need is "zip a user-selected subset of assets across three containers with Glide-converted derivatives, queued, emailed" — the addon is a starting point, not a solution, and you'll be wrapping it in your own controller.

Implementation Patterns

Download whole container / subtree. The simplest integration: a route in your own app that resolves the container and delegates to the addon's zip builder, so you control authorization and naming instead of inheriting whatever the package registered:

Route::middleware(['web', 'auth'])->get('/exports/assets/{container}/{path?}', function (string $container, ?string $path = null) {
    $container = AssetContainer::findByHandle($container);
    abort_unless($container, 404);
    abort_unless(auth()->user()->can('view', $container), 403);
    // delegate to the addon's zip/stream helper here
})->where('path', '.*');

User-selected assets (the pattern you'll actually want). Give the listing checkboxes, POST the selected IDs or paths, and build the zip from that set. This is where you stop relying on the addon's one-shot route and start using it as a library — resolve Asset models yourself and hand the addon a collection:

$assets = collect($request->input('assets'))
    ->map(fn ($id) => Asset::find($id))
    ->filter()
    ->filter(fn (Asset $a) => Gate::allows('view', $a));

return $zipper->zip($assets)->name('selection-'.now()->format('Ymd-His'));

The value the package adds is the response/headers/streaming wrapper; the asset resolution should be yours, because only you know the permission model.

Let the package cache for you — don't build your own. Since v2 the addon owns the cached-archive lifecycle: config carries the cache/TTL keys, and the same container+path request is served from a previously built archive until it expires. Turn this on deliberately and tune the TTL to how stale you can tolerate a listing being (asset adds/removes don't necessarily bust it — see Gotchas). Only roll your own max(updated_at)-keyed archive if you need invalidation on asset save, which is a real requirement for most editorial workflows.

Signed, expiring downloads. Never let a zip URL be a stable public path into storage. Generate a URL::temporarySignedRoute() for the export and let the addon stream from it. This is the single most important pattern for anything containing non-public assets.

Glide / derived images. Zipping an asset's url() re-fetches over HTTP (slow, and it hits Glide's endpoint). Use $asset->path()/local stream for originals. If you need resized derivatives in the zip, warm the Glide cache first (Glide::get($asset->url()) or equivalent), then add the cached file — and accept that this doubles the work. For large sets, generate the derived images into a temp dir in a queued job.

Queued generation for anything non-trivial. Above ~200 MB or ~1,000 files, move to a job:

ExportZipJob::dispatch($containerHandle, $paths, $user->id);
// writes to storage/app/exports/{uuid}.zip, then Notification::send(...)

Then serve the finished file via a signed route with response()->download(). The addon's on-the-fly streaming is great for small sets; it's the wrong tool when the request could time out.

Scheduled/cleanup. A zip:prune style command (write your own; don't assume the package ships one) registered in the scheduler to delete storage/app/exports/*.zip and the addon's cache directory older than N days. Exports and cached archives are the classic forgotten-disk-filler — if you enable caching, your disk usage now grows without a user clicking anything.

Testing. Assert on zip contents, not on the HTTP 200 — and test twice in a row, because the second request is the cached path and that's exactly where v2.5.1's bug lived:

Storage::fake('local');
$response = $this->actingAs($user)->get($signedUrl);
$zip = new ZipArchive();
$zip->open($response->baseResponse->getFile()->getPathname());
$this->assertSame(3, $zip->numFiles);
$this->assertNotFalse($zip->locateName('photos/hero.jpg'));

// hit it again — cached branch
$cached = $this->actingAs($user)->get($signedUrl);
$this->assertSame(200, $cached->status());
$this->assertGreaterThan(0, strlen($cached->streamedContent() ?? 'x'));

Integration tip: wrap the addon behind one class of your own (App\Support\AssetZipper) that exposes ->forAssets(Collection), ->forFolder(string), ->stream() / ->store(). When the addon inevitably needs a patch, or you outgrow it, you change one file.

Gotchas and Tips

Upgrade to ≥2.5.1 if you use cached downloads. Prior to v2.5.1, downloading an already-cached zip was broken (PR #20). Symptom is a request that returns 200 but yields an empty/0-byte or corrupt file — and only on the second request, which is why it survives local testing and hits production. After upgrading, purge any existing cache entries; archives written by the buggy code path may be sitting there and will keep serving bad bytes until the TTL expires.

Cache staleness is now your problem, not a missing feature. Caching means "Download all" can hand a user a folder state from an hour ago. Either keep the TTL short for editor-facing buttons, key your own archive on max(updated_at) and invalidate on AssetSaved, or disable caching for containers that churn.

Remote filesystems are the landmine. If your container uses s3 (or any non-local disk), $asset->path() is not a local path and ZipArchive::addFile() will fail silently or produce an empty/corrupt archive. You must stream remote contents into the zip (addFromString($name, Storage::disk($d)->get($path))) — which loads the whole file into memory. Statamic's S3 adapter is common; assume this is your first troubleshooting step.

Memory and timeout. ZipArchive writes to a temp file so it's not fully memory-bound, but building the asset collection (Asset::all() on a 10k-file container) absolutely is. Always paginate/lazy-load when collecting paths. Bump memory_limit and max_execution_time for the route, or better, queue it.

Path traversal. If the route accepts a path segment, ../../.env is one request away. Validate that the resolved path (after realpath()) is still inside the container's root. Statamic's AssetContainer::asset($path) helps, but don't trust it blindly — resolve and assert.

Duplicate basenames. Flattening a recursive folder into a zip will collide (a/1.jpg and b/1.jpg → two 1.jpg entries; ZipArchive overwrites, other writers error). Preserve relative paths, or dedupe with a suffix.

Content-Disposition and non-ASCII. Filenames with spaces/umlauts need RFC 5987 (filename*=UTF-8''...) or browsers mangle them. If the package sets a static header, you may need to override the response to control naming.

Permission checks are almost certainly not included. A package that streams assets usually doesn't know about your roles. The CP's asset access rules are not automatically applied to a custom route — and a cached archive makes this worse, because the cached file is shared across users. Add a Gate/can check per container and per asset, before the cache lookup. Verify as a low-privilege user before shipping.

CSRF and verb. A GET download link is fine but means the zip spec is in the URL (and in logs). A POST needs @csrf and can't be triggered by a plain <a>. Choose deliberately.

Don't zip url() values if assets are behind Glide or a CDN. url() may be an absolute CDN URL — your zip becomes a slow, rate-limited HTTP crawl. Use storage paths.

Empty/placeholder folders and missing files. ZipArchive::addFile() returns false without a useful error and leaves no entry. Check file_exists() and log skips; otherwise users report "files missing from zip" and you can't reproduce.

Version pinning is non-negotiable here. 1 star and 0 dependents means no downstream pressure-tested path and no community bug reports to lean on — v2.5.1's own release note is a good illustration: a cached-download regression that shipped in v2.x and was only fixed in a patch. Pin an exact version or a tight caret ("aerni/zipper": "^2.5.1" inside v2, not ^2.0), read the diff before upgrading, and keep the wrapper class so you can vendor a one-file fork without touching call sites. If you're still on 1.x, the jump to 2.x is a major — check the CHANGELOG for signature/config renames rather than assuming your 1.x code and config stub still apply.

Extension points, in order of usefulness: (1) wrap the package in your own service — no forking required; (2) publish and edit config/zipper.php to restrict containers, set naming, and tune the cache/TTL; (3) register your own route and use the package's builder class directly; (4) override the published view if the download button ships one. Only fork if the streaming response itself is the problem (e.g. you need ZipStream for true streaming without a temp file).

Debugging checklist: php -m | grep zip → container disk driver → is the request hitting the cache branch (clear it and retest) → storage/logs/laravel.log → unzip -l path/to/file.zip to inspect what was actually written → confirm the request hit your route (php artisan route:list --path=zipper). If the archive downloads as a 0-byte file, it's almost always output already sent (whitespace/BOM from a Blade template), an exception swallowed mid-stream, or — on <2.5.1 — a cached zip.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin