madnest/madzipper
Madzipper is a Laravel package that wraps PHP’s ZipArchive with a fluent, chainable API for creating, extracting, and manipulating ZIP archives. Add files or folders, extract specific patterns, and manage archives using readable methods—ideal for backups, downloads, and file packaging in Laravel app...
Madzipper is a thin, fluent Laravel wrapper around PHP's ZipArchive (a maintained fork of Chumper/Zipper). It gives you a chainable API for creating, appending to, reading, and extracting archives without touching ZipArchive's handle-based API directly.
Requirements: PHP with ext-zip enabled (php -m | grep zip) and a Laravel version covered by the package's constraint — as of v1.7.0 that now includes Laravel 13 alongside the 9–12 line.
composer require madnest/madzipper
The service provider and Zipper/Madzipper facade are auto-discovered. Publishing the config is optional but recommended the first time you need to change behaviour:
php artisan vendor:publish --tag=config
Where to look first:
Madnest\Madzipper\Facades\Madzipper — the entire public surface is the fluent chain on this facade.config/zipper.php — holds options.skip_errors, default_os, default_os_mode, and the status_codes map used for error messages. This is where 90% of "why did it silently fail?" answers live.First use case — zip a directory of generated invoices and hand it to the browser:
use Madnest\Madzipper\Facades\Madzipper;
$zip = Madzipper::make(storage_path('app/exports/invoices.zip'));
$zip->add(storage_path('app/invoices')) // directory: added recursively
->close(); // <-- required, see Gotchas
return response()->download(storage_path('app/exports/invoices.zip'))->deleteFileAfterSend();
If that works, you've exercised the two operations you'll use 80% of the time: add() and close().
The common shape is: create at a temp path, add, close(), stream, delete.
$path = tempnam(sys_get_temp_dir(), 'zip');
Madzipper::make($path)
->add($files) // string or array of paths
->folder('reports') // everything after this lands in /reports
->add($reportFiles)
->close();
return response()->download($path)->deleteFileAfterSend(true);
folder() sets the prefix for subsequent add() calls only — it is stateful, so order matters. Call folder() again to switch, and be aware there's no "pop folder" — track your own nesting or rebuild.
Useful for config dumps, CSV rendered in memory, or a README injected into every export:
Madzipper::make($path)
->addString(json_encode($manifest, JSON_PRETTY_PRINT), 'manifest.json')
->addString($csv, 'data/rows.csv') // subpaths work here too
->close();
$zip = Madzipper::make($request->file('bundle')->getRealPath());
$names = $zip->listFiles(); // flat array of entry names
$json = $zip->getFileContent('manifest.json'); // false on failure
$zip->extractTo(storage_path('app/imports')); // whole archive
$zip->extractTo($dest, ['manifest.json', 'data/']); // selected entries
Validate listFiles() against an allow-list before extracting — see Gotchas on path traversal.
make() on an existing path opens it for modification rather than truncating, which makes incremental updates cheap:
Madzipper::make($archive)
->delete('stale/report.pdf')
->deleteMatching('*.tmp') // pattern-based cleanup
->add($freshFiles)
->close();
Note that deleting then re-adding the same name does not guarantee ordering or dedup — treat the archive as a set keyed by entry name.
Do archive building in a job, not the request cycle. ZipArchive is CPU-and-IO bound and will happily blow past max_execution_time on a few hundred MB.
public function handle(): void
{
$zip = Madzipper::make($this->archivePath);
foreach ($this->paths->chunk(200) as $chunk) {
$zip->add($chunk->all());
}
$zip->close(); // must happen inside the job, before you mark it done
}
Because the instance holds an open file handle, never serialise it into a job payload or stash it on a singleton. Create it inside handle() and let it go out of scope.
ZipArchive writes to the real filesystem, so Storage::fake() works only because the fake disk is a real local directory — the archive path must be a local path, not an S3 URL. Extract or read back and assert with listFiles():
Storage::fake('local');
// ... run the action ...
$this->assertContains('manifest.json', Madzipper::make($path)->listFiles());
For anything beyond listFiles()/getFileContent(), assert on getStatus() rather than trusting a boolean.
close() is not optional. Nothing is flushed to disk until you close. A missing close() (or an exception thrown mid-chain) leaves you with a zero-byte or truncated archive and no error — the classic "download works but the zip is corrupt" bug. Wrap builds in try/finally if any add() can throw.
Relative paths resolve against base_path(). Madzipper runs non-absolute paths through its own path resolution before handing them to ZipArchive, so add('storage/app/x.pdf') means project root/storage/..., not your current working directory. Absolute paths sidestep the ambiguity entirely — prefer them.
Failures are quiet by default. options.skip_errors in config/zipper.php means a missing file or unreadable entry is skipped rather than raised. Great for "best effort" exports, terrible for correctness. Turn it off in environments where a partial archive is a bug, and check getStatus()/getErrorMessage() after close():
$zip->close();
if ($zip->getStatus() !== 0) {
throw new RuntimeException($zip->getErrorMessage());
}
Octal file modes changed in PHP 8.1. default_os_mode in the config is an octal literal. Older configs use 0666; under modern PHP write it as 0o666 (or 0666 still parses, but be deliberate) — a stray decimal 666 produces nonsense permissions on extracted files. Only relevant when default_os forces Unix perms.
Extraction is a zip-slip risk. extractTo() historically does not sanitise entry names. For any archive you didn't create, either reject entries containing .. or a leading / from listFiles() first, or extract into a throwaway directory and move files yourself.
Memory and time scale with archive size. ZipArchive buffers entry metadata in memory and rewrites the central directory on close. Chunk your add() calls, raise memory_limit for the job, and avoid extractTo() on multi-GB archives — iterate entries and stream them individually.
Name collisions are last-write-wins. Adding the same entry path twice leaves one entry with no warning. If you build archives from overlapping globs, normalise and dedupe your file list before calling add().
Windows separators. Entry names are stored with forward slashes. If you build paths with DIRECTORY_SEPARATOR you'll get backslashes inside the archive, which confuses Linux extractors and any deleteMatching() pattern. Always join with /.
deleteMatching() is a glob, not a regex. '*.txt' matches, '/\.txt$/' does not. Match against the full entry path including folders — *.txt will not catch docs/a.txt in every case, so test with listFiles() first.
Laravel 13 support (v1.7.0) is a constraint widening, not an API change. No deprecations, no signature changes, no config key renames — the fluent surface is identical to v1.6.x. The only practical impact is that you can now drop the version pin if you're moving an app to Laravel 13; nothing in existing archive code needs touching. If composer still refuses to update, check the resolver in this order: ext-zip on the CLI binary, your php constraint, then any other package pinning laravel/framework below 13.
Extension points are thin by design. There's no compression-method or encryption knob exposed — if you need ZipArchive::CM_STORE, AES-256, or per-entry comments, drop to ZipArchive directly for that file and keep Madzipper for the CRUD paths. For anything cross-cutting (logging every archive built, injecting a standard manifest), decorate a small ArchiveBuilder service that wraps the facade rather than subclassing the repository.
How can I help you explore Laravel packages today?