Zip #
The ability to create and extract ZIP archives is useful in many web application scenarios: exporting data as a collection of CSV or PDF files, distributing assets (themes, plugins, templates), backing up user uploads, or bundling reports for download. PHP provides the ZipArchive extension, which is included in most modern PHP installations — no external library needed. This article covers all aspects of ZipArchive, from creating simple archives to more complex patterns like streaming large ZIPs directly to the browser without saving to disk, and archive encryption.
Creating ZIP Archives #
<?php
$zip = new ZipArchive();
// Open or create a ZIP file
// ZipArchive::CREATE — create new (or open an existing one)
// ZipArchive::OVERWRITE — create new, overwrite if it exists
// ZipArchive::EXCL — create new, fail if it already exists
// ZipArchive::RDONLY — open read-only
$result = $zip->open('archive.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($result !== true) {
$message = match($result) {
ZipArchive::ER_EXISTS => "File already exists",
ZipArchive::ER_INCONS => "Archive is inconsistent",
ZipArchive::ER_INVAL => "Invalid arguments",
ZipArchive::ER_MEMORY => "Out of memory",
ZipArchive::ER_NOENT => "File not found",
ZipArchive::ER_NOZIP => "Not a ZIP archive",
ZipArchive::ER_OPEN => "Failed to open the file",
ZipArchive::ER_READ => "Failed to read the file",
ZipArchive::ER_SEEK => "Failed to seek",
default => "Unknown error: $result",
};
throw new \RuntimeException("Failed to create ZIP: $message");
}
// Add a file from disk
$zip->addFile('report.pdf', 'report.pdf'); // source file, name in the zip
$zip->addFile('/path/to/data.csv', 'data/export.csv'); // can be placed in a subfolder
// Add content from a string (no source file on disk)
$zip->addFromString('readme.txt', "This is a readme file\nCreated: " . date('Y-m-d'));
$zip->addFromString('config.json', json_encode(['version' => '1.0'], JSON_PRETTY_PRINT));
$zip->addFromString('report/summary.html', '<h1>Report Summary</h1>');
// Add empty directories
$zip->addEmptyDir('backup');
$zip->addEmptyDir('backup/database');
$zip->addEmptyDir('backup/files');
// Set a comment on the archive
$zip->setArchiveComment("Backup created on " . date('Y-m-d H:i:s'));
// Set a comment on a specific file
$zip->setCommentIndex(0, "Main report file");
// Close and save to disk
if (!$zip->close()) {
throw new \RuntimeException("Failed to save the ZIP archive");
}
echo "ZIP created successfully: archive.zip\n";
Adding Directories Recursively #
<?php
function addDirToZip(ZipArchive $zip, string $sourceDir, string $zipPrefix = ''): void
{
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(
$sourceDir,
RecursiveDirectoryIterator::SKIP_DOTS
),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
$relativePath = $zipPrefix . $iterator->getSubPathname();
// Replace backslashes with forward slashes (Windows compatibility)
$relativePath = str_replace('\\', '/', $relativePath);
if ($item->isDir()) {
$zip->addEmptyDir($relativePath);
} else {
$zip->addFile($item->getPathname(), $relativePath);
}
}
}
// Create a ZIP from an entire directory
$zip = new ZipArchive();
$zip->open('project_backup.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE);
addDirToZip($zip, '/var/www/app/src', 'src/');
addDirToZip($zip, '/var/www/app/config', 'config/');
// Add individual files at the ZIP root
$zip->addFile('/var/www/app/composer.json', 'composer.json');
$zip->addFile('/var/www/app/README.md', 'README.md');
$zip->close();
// With a filter — only add PHP and JSON files
function addDirWithFilter(
ZipArchive $zip,
string $sourceDir,
string $zipPrefix = '',
array $allowedExtensions = ['php', 'json', 'yaml', 'md'],
): void {
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($sourceDir, RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (!$file->isFile()) continue;
$extension = strtolower($file->getExtension());
if (!in_array($extension, $allowedExtensions, strict: true)) continue;
// Skip vendor and node_modules directories
$path = str_replace('\\', '/', $file->getPathname());
if (str_contains($path, '/vendor/') || str_contains($path, '/node_modules/')) {
continue;
}
$relativePath = $zipPrefix . $iterator->getSubPathname();
$relativePath = str_replace('\\', '/', $relativePath);
$zip->addFile($file->getPathname(), $relativePath);
}
}
Extracting ZIP Archives #
<?php
$zip = new ZipArchive();
if ($zip->open('archive.zip') !== true) {
throw new \RuntimeException("Failed to open the ZIP archive");
}
// Extract everything to a directory
$zip->extractTo('/path/to/destination/');
// Extract only specific files
$zip->extractTo('/destination/', 'readme.txt');
$zip->extractTo('/destination/', ['file1.txt', 'subfolder/file2.csv']);
// Archive information
echo "File count: " . $zip->numFiles . "\n";
echo "Comment: " . $zip->getArchiveComment() . "\n";
// Iterate all files in the archive
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
echo sprintf(
"%s (%s bytes, compressed: %s bytes)\n",
$stat['name'],
number_format($stat['size']),
number_format($stat['comp_size'])
);
}
// Or by name
$stat = $zip->statName('report.pdf');
if ($stat !== false) {
echo "Size: " . $stat['size'] . " bytes\n";
}
$zip->close();
// Extract with security validation (prevent path traversal)
function safeExtract(string $zipPath, string $destination): void
{
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) {
throw new \RuntimeException("Failed to open ZIP");
}
$destReal = realpath($destination) . DIRECTORY_SEPARATOR;
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
// Prevent path traversal: '../', '/', etc.
if (str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, ':')) {
$zip->close();
throw new \RuntimeException("Dangerous path found in ZIP: $name");
}
$targetPath = $destReal . $name;
// Make sure the final resolved path is still inside the destination directory
if (!str_starts_with(realpath(dirname($targetPath)) . DIRECTORY_SEPARATOR, $destReal)) {
$zip->close();
throw new \RuntimeException("Path traversal detected: $name");
}
if (str_ends_with($name, '/')) {
mkdir($targetPath, 0755, true);
} else {
$parentDir = dirname($targetPath);
if (!is_dir($parentDir)) {
mkdir($parentDir, 0755, true);
}
file_put_contents($targetPath, $zip->getFromIndex($i));
}
}
$zip->close();
}
Reading Contents Without Extracting #
<?php
$zip = new ZipArchive();
$zip->open('archive.zip');
// Read a specific file's content as a string
$readmeContent = $zip->getFromName('readme.txt');
if ($readmeContent !== false) {
echo $readmeContent;
}
// Read by index
$content = $zip->getFromIndex(0);
// Read as a stream (for large files inside the ZIP)
$stream = $zip->getStream('data/large.csv');
if ($stream !== false) {
while (!feof($stream)) {
$line = fgets($stream, 4096);
if ($line !== false) {
// process the line...
}
}
fclose($stream);
}
// Check whether a file exists in the ZIP
if ($zip->statName('config.json') !== false) {
$config = json_decode($zip->getFromName('config.json'), true);
}
$zip->close();
Modifying Existing Archives #
<?php
$zip = new ZipArchive();
$zip->open('archive.zip'); // without CREATE — open the existing one
// Add a new file to the existing archive
$zip->addFromString('changelog.txt', "v2.0 — Update on " . date('Y-m-d'));
// Delete a file from the archive
$zip->deleteName('old_file.txt');
$zip->deleteIndex(3); // delete the file at index 3
// Rename a file in the archive
$zip->renameName('readme.txt', 'README.md');
$zip->renameIndex(0, 'documents/report.pdf');
// Replace the content of an existing file
$zip->deleteName('config.json');
$zip->addFromString('config.json', json_encode(['version' => '2.0'], JSON_PRETTY_PRINT));
$zip->close();
ZIP Encryption #
PHP 7.2+ supports AES encryption for files inside ZIP archives:
<?php
$zip = new ZipArchive();
$zip->open('secret.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE);
// Add files
$zip->addFromString('sensitive_data.json', json_encode(['token' => 'abc123', 'key' => 'secret']));
$zip->addFile('financial_report.pdf', 'report.pdf');
// Set the password for ALL added files
$password = 'a-strong-secret-password';
$zip->setPassword($password);
// Encrypt per file (ZIP_EM_AES_256 — AES 256-bit)
for ($i = 0; $i < $zip->numFiles; $i++) {
$zip->setEncryptionIndex($i, ZipArchive::EM_AES_256);
}
// Or encrypt everything at once
// $zip->setEncryptionName('sensitive_data.json', ZipArchive::EM_AES_256);
$zip->close();
// Open an encrypted ZIP
$zip->open('secret.zip');
$zip->setPassword($password);
$content = $zip->getFromName('sensitive_data.json');
// Succeeds if the password is correct
$zip->close();
// Available encryption constants:
// ZipArchive::EM_NONE — not encrypted
// ZipArchive::EM_TRAD_PKWARE — traditional encryption (weak, avoid)
// ZipArchive::EM_AES_128 — AES 128-bit
// ZipArchive::EM_AES_192 — AES 192-bit
// ZipArchive::EM_AES_256 — AES 256-bit (recommended)
Streaming ZIPs to the Browser #
For large or dynamically created ZIP files, stream directly to the browser without saving to disk first:
<?php
// Stream a ZIP to the browser using php://output
function streamZipToBrowser(array $files, string $fileName = 'download.zip'): void
{
// Set download headers
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . rawurlencode($fileName) . '"');
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
// Create the ZIP to a temp file
$zip = new ZipArchive();
$tmpFile = tempnam(sys_get_temp_dir(), 'zip_');
$zip->open($tmpFile, ZipArchive::OVERWRITE);
foreach ($files as $pathInZip => $content) {
if (is_string($content) && file_exists($content)) {
// Content is a file path
$zip->addFile($content, $pathInZip);
} else {
// Content is a string
$zip->addFromString($pathInZip, (string) $content);
}
}
$zip->close();
// Stream the temp file to the browser
header('Content-Length: ' . filesize($tmpFile));
readfile($tmpFile);
// Clean up
unlink($tmpFile);
exit;
}
// Usage
streamZipToBrowser([
'report/Q1_2024.csv' => '/data/report_q1.csv', // path to a file
'report/Q2_2024.csv' => '/data/report_q2.csv',
'readme.txt' => "Annual report 2024\nCreated: " . date('Y-m-d'),
'metadata.json' => json_encode(['year' => 2024, 'total' => 1250000]),
], 'report_2024.zip');
Common Data Export Patterns #
<?php
// Export database query results as a ZIP containing several files
function exportEmployeeData(PDO $pdo, string $year): string
{
$zipPath = sys_get_temp_dir() . '/employee_export_' . $year . '_' . time() . '.zip';
$zip = new ZipArchive();
$zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
// File 1: Employee list (CSV)
$stmt = $pdo->prepare("SELECT id, name, email, department, salary FROM employees WHERE year = ?");
$stmt->execute([$year]);
$csvEmployees = "ID,Name,Email,Department,Salary\n";
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$csvEmployees .= implode(',', array_map(fn($v) => '"' . str_replace('"', '""', $v) . '"', $row)) . "\n";
}
$zip->addFromString("employees_$year.csv", $csvEmployees);
// File 2: Summary per department
$stmt = $pdo->prepare("
SELECT department, COUNT(*) as count, AVG(salary) as avg_salary
FROM employees WHERE year = ?
GROUP BY department ORDER BY department
");
$stmt->execute([$year]);
$summary = $stmt->fetchAll(PDO::FETCH_ASSOC);
$csvSummary = "Department,Employee Count,Average Salary\n";
foreach ($summary as $row) {
$csvSummary .= "\"{$row['department']}\",{$row['count']}," . number_format($row['avg_salary'], 0) . "\n";
}
$zip->addFromString("department_summary_$year.csv", $csvSummary);
// File 3: Metadata
$zip->addFromString('export_info.json', json_encode([
'year' => $year,
'total' => count($summary),
'exported_at' => date('Y-m-d H:i:s'),
'version' => '1.0',
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// File 4: README
$zip->addFromString('README.txt', implode("\n", [
"EMPLOYEE DATA EXPORT $year",
str_repeat('=', 30),
"Created: " . date('Y-m-d H:i:s'),
"",
"Archive contents:",
"- employees_$year.csv : Complete employee list",
"- department_summary_$year.csv : Summary per department",
"- export_info.json : Export metadata",
]));
$zip->close();
return $zipPath;
}
// In a controller
$zipPath = exportEmployeeData($pdo, '2024');
// Send to the browser
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="employee_data_2024.zip"');
header('Content-Length: ' . filesize($zipPath));
header('Cache-Control: no-cache');
readfile($zipPath);
unlink($zipPath); // delete the temp file after sending
exit;
Common ZIP Anti-Patterns #
<?php
// ✗ Anti-pattern 1: not validating content before extracting (zip bombs & path traversal)
$zip = new ZipArchive();
$zip->open($_FILES['upload']['tmp_name']);
$zip->extractTo('/var/www/uploads/'); // DANGEROUS! path traversal and zip bombs
$zip->close();
// ✓ Validate size and paths before extracting (see the safeExtract() function above)
// ✗ Anti-pattern 2: not checking the open() return value
$zip = new ZipArchive();
$zip->open('missing.zip'); // returns false but isn't checked
$zip->extractTo('/tmp'); // fatal error!
// ✓ Always check the return value
$result = $zip->open('file.zip');
if ($result !== true) {
throw new \RuntimeException("Failed to open ZIP: $result");
}
// ✗ Anti-pattern 3: not deleting temp files when done
$tmp = tempnam(sys_get_temp_dir(), 'zip_');
$zip->open($tmp, ZipArchive::CREATE);
// ... process ...
$zip->close();
// Forgot unlink($tmp) — temp files pile up in /tmp!
// ✓ Use try/finally for guaranteed cleanup
$tmp = tempnam(sys_get_temp_dir(), 'zip_');
try {
$zip = new ZipArchive();
$zip->open($tmp, ZipArchive::CREATE);
// process...
$zip->close();
readfile($tmp);
} finally {
if (file_exists($tmp)) {
unlink($tmp);
}
}
// ✗ Anti-pattern 4: zip bombs — small files that extract to something huge
// Check the compression ratio before extracting
function checkZipBomb(string $zipPath, float $maxRatio = 10.0, int $maxSizeMB = 100): void
{
$zip = new ZipArchive();
$zip->open($zipPath);
$totalOriginalSize = 0;
$compressedSize = filesize($zipPath);
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$totalOriginalSize += $stat['size'];
}
$zip->close();
$ratio = $compressedSize > 0 ? $totalOriginalSize / $compressedSize : 0;
if ($ratio > $maxRatio) {
throw new \RuntimeException("Suspicious compression ratio: $ratio (max $maxRatio)");
}
if ($totalOriginalSize > $maxSizeMB * 1024 * 1024) {
throw new \RuntimeException("Total extract size too large: " . round($totalOriginalSize / 1024 / 1024) . " MB");
}
}
Summary #
ZipArchive::open()returnstrueor an integer error code — always check with=== true, not just truthiness.addFile($path, $nameInZip)for files from disk;addFromString($name, $content)for content from memory. Neither requires closing and reopening — justclose()at the end.- Add directories recursively with
RecursiveIteratorIterator+RecursiveDirectoryIterator— more flexible than manual loops and filterable by extension or path.- Validate ZIP contents before extracting — prevent path traversal by ensuring all paths don’t contain
..and the realpath result stays inside the destination directory. Prevent zip bombs by checking the compression ratio.- Stream ZIPs to the browser via a temp file +
readfile()+unlink()— avoid storing large ZIPs in a web directory that’s publicly accessible.getFromName()andgetStream()for reading file contents inside a ZIP without extracting to disk — useful for validation or direct processing.- AES-256 encryption with
setEncryptionIndex($i, ZipArchive::EM_AES_256)— available since PHP 7.2, always use AES instead of the weak traditional PKWARE encryption.- Use
try/finallyto make sure temp files are always deleted — even if an exception occurs mid-process.
← Previous: SPL