Zip

Zip #

Kemampuan membuat dan mengekstrak arsip ZIP sangat berguna di banyak skenario aplikasi web: export data sebagai kumpulan file CSV atau PDF, distribusi aset (tema, plugin, template), backup file upload pengguna, atau bundling report untuk diunduh. PHP menyediakan ekstensi ZipArchive yang sudah termasuk di sebagian besar instalasi PHP modern — tidak perlu library eksternal. Artikel ini membahas semua aspek ZipArchive dari pembuatan arsip sederhana hingga pola yang lebih kompleks seperti streaming ZIP besar langsung ke browser tanpa menyimpan ke disk dan enkripsi arsip.

Membuat Arsip ZIP #

<?php
$zip = new ZipArchive();

// Buka atau buat file ZIP
// ZipArchive::CREATE — buat baru (atau buka yang ada)
// ZipArchive::OVERWRITE — buat baru, timpa jika sudah ada
// ZipArchive::EXCL — buat baru, gagal jika sudah ada
// ZipArchive::RDONLY — buka hanya baca
$hasil = $zip->open('arsip.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE);

if ($hasil !== true) {
    $pesan = match($hasil) {
        ZipArchive::ER_EXISTS  => "File sudah ada",
        ZipArchive::ER_INCONS  => "Arsip tidak konsisten",
        ZipArchive::ER_INVAL   => "Argumen tidak valid",
        ZipArchive::ER_MEMORY  => "Memori tidak cukup",
        ZipArchive::ER_NOENT   => "File tidak ditemukan",
        ZipArchive::ER_NOZIP   => "Bukan arsip ZIP",
        ZipArchive::ER_OPEN    => "Gagal membuka file",
        ZipArchive::ER_READ    => "Gagal membaca file",
        ZipArchive::ER_SEEK    => "Gagal seek",
        default                => "Error tidak dikenal: $hasil",
    };
    throw new \RuntimeException("Gagal membuat ZIP: $pesan");
}

// Tambah file dari disk
$zip->addFile('laporan.pdf', 'laporan.pdf');             // file sumber, nama dalam zip
$zip->addFile('/path/to/data.csv', 'data/ekspor.csv');  // bisa taruh dalam subfolder

// Tambah konten dari string (tanpa file sumber di disk)
$zip->addFromString('readme.txt', "Ini adalah file readme\nDibuat: " . date('Y-m-d'));
$zip->addFromString('config.json', json_encode(['versi' => '1.0'], JSON_PRETTY_PRINT));
$zip->addFromString('laporan/summary.html', '<h1>Ringkasan Laporan</h1>');

// Tambah direktori kosong
$zip->addEmptyDir('backup');
$zip->addEmptyDir('backup/database');
$zip->addEmptyDir('backup/files');

// Set komentar pada arsip
$zip->setArchiveComment("Backup dibuat pada " . date('Y-m-d H:i:s'));

// Set komentar pada file tertentu
$zip->setCommentIndex(0, "File laporan utama");

// Tutup dan simpan ke disk
if (!$zip->close()) {
    throw new \RuntimeException("Gagal menyimpan arsip ZIP");
}

echo "ZIP berhasil dibuat: arsip.zip\n";

Menambahkan Direktori Secara Rekursif #

<?php
function tambahDirKeZip(ZipArchive $zip, string $dirSumber, string $prefixDalamZip = ''): void
{
    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator(
            $dirSumber,
            RecursiveDirectoryIterator::SKIP_DOTS
        ),
        RecursiveIteratorIterator::SELF_FIRST
    );

    foreach ($iterator as $item) {
        $pathRelative = $prefixDalamZip . $iterator->getSubPathname();

        // Ganti backslash jadi forward slash (Windows compatibility)
        $pathRelative = str_replace('\\', '/', $pathRelative);

        if ($item->isDir()) {
            $zip->addEmptyDir($pathRelative);
        } else {
            $zip->addFile($item->getPathname(), $pathRelative);
        }
    }
}

// Buat ZIP dari seluruh direktori
$zip = new ZipArchive();
$zip->open('backup_project.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE);

tambahDirKeZip($zip, '/var/www/app/src', 'src/');
tambahDirKeZip($zip, '/var/www/app/config', 'config/');

// Tambah file individual di root ZIP
$zip->addFile('/var/www/app/composer.json', 'composer.json');
$zip->addFile('/var/www/app/README.md', 'README.md');

$zip->close();

// Dengan filter — hanya tambah file PHP dan JSON
function tambahDirDenganFilter(
    ZipArchive $zip,
    string     $dirSumber,
    string     $prefixDalamZip = '',
    array      $ekstensiDiizinkan = ['php', 'json', 'yaml', 'md'],
): void {
    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dirSumber, RecursiveDirectoryIterator::SKIP_DOTS)
    );

    foreach ($iterator as $file) {
        if (!$file->isFile()) continue;

        $ekstensi = strtolower($file->getExtension());
        if (!in_array($ekstensi, $ekstensiDiizinkan, strict: true)) continue;

        // Lewati direktori vendor dan node_modules
        $path = str_replace('\\', '/', $file->getPathname());
        if (str_contains($path, '/vendor/') || str_contains($path, '/node_modules/')) {
            continue;
        }

        $pathRelative = $prefixDalamZip . $iterator->getSubPathname();
        $pathRelative = str_replace('\\', '/', $pathRelative);
        $zip->addFile($file->getPathname(), $pathRelative);
    }
}

Mengekstrak Arsip ZIP #

<?php
$zip = new ZipArchive();

if ($zip->open('arsip.zip') !== true) {
    throw new \RuntimeException("Gagal membuka arsip ZIP");
}

// Ekstrak semua ke direktori
$zip->extractTo('/path/to/destination/');

// Ekstrak file tertentu saja
$zip->extractTo('/destination/', 'readme.txt');
$zip->extractTo('/destination/', ['file1.txt', 'subfolder/file2.csv']);

// Informasi arsip
echo "Jumlah file: " . $zip->numFiles . "\n";
echo "Komentar: " . $zip->getArchiveComment() . "\n";

// Iterasi semua file dalam arsip
for ($i = 0; $i < $zip->numFiles; $i++) {
    $stat = $zip->statIndex($i);
    echo sprintf(
        "%s (%s bytes, dikompres: %s bytes)\n",
        $stat['name'],
        number_format($stat['size']),
        number_format($stat['comp_size'])
    );
}

// Atau dengan nama
$stat = $zip->statName('laporan.pdf');
if ($stat !== false) {
    echo "Ukuran: " . $stat['size'] . " bytes\n";
}

$zip->close();

// Ekstrak dengan validasi keamanan (cegah path traversal)
function ekstrakAman(string $zipPath, string $destinasi): void
{
    $zip = new ZipArchive();
    if ($zip->open($zipPath) !== true) {
        throw new \RuntimeException("Gagal membuka ZIP");
    }

    $destinasiReal = realpath($destinasi) . DIRECTORY_SEPARATOR;

    for ($i = 0; $i < $zip->numFiles; $i++) {
        $nama = $zip->getNameIndex($i);

        // Cegah path traversal: '../', '/', dll.
        if (str_contains($nama, '..') || str_starts_with($nama, '/') || str_contains($nama, ':')) {
            $zip->close();
            throw new \RuntimeException("Path berbahaya ditemukan dalam ZIP: $nama");
        }

        $targetPath = $destinasiReal . $nama;

        // Pastikan hasil akhir path masih dalam direktori tujuan
        if (!str_starts_with(realpath(dirname($targetPath)) . DIRECTORY_SEPARATOR, $destinasiReal)) {
            $zip->close();
            throw new \RuntimeException("Path traversal terdeteksi: $nama");
        }

        if (str_ends_with($nama, '/')) {
            mkdir($targetPath, 0755, true);
        } else {
            $dirParent = dirname($targetPath);
            if (!is_dir($dirParent)) {
                mkdir($dirParent, 0755, true);
            }
            file_put_contents($targetPath, $zip->getFromIndex($i));
        }
    }

    $zip->close();
}

Membaca Konten Tanpa Mengekstrak #

<?php
$zip = new ZipArchive();
$zip->open('arsip.zip');

// Baca konten file tertentu sebagai string
$isiReadme = $zip->getFromName('readme.txt');
if ($isiReadme !== false) {
    echo $isiReadme;
}

// Baca by index
$isi = $zip->getFromIndex(0);

// Baca sebagai stream (untuk file besar dalam ZIP)
$stream = $zip->getStream('data/besar.csv');
if ($stream !== false) {
    while (!feof($stream)) {
        $baris = fgets($stream, 4096);
        if ($baris !== false) {
            // proses baris...
        }
    }
    fclose($stream);
}

// Cek apakah file ada dalam ZIP
if ($zip->statName('config.json') !== false) {
    $config = json_decode($zip->getFromName('config.json'), true);
}

$zip->close();

Modifikasi Arsip yang Sudah Ada #

<?php
$zip = new ZipArchive();
$zip->open('arsip.zip'); // tanpa CREATE — buka yang sudah ada

// Tambah file baru ke arsip yang ada
$zip->addFromString('changelog.txt', "v2.0 — Update tanggal " . date('Y-m-d'));

// Hapus file dari arsip
$zip->deleteName('file_lama.txt');
$zip->deleteIndex(3); // hapus file di indeks 3

// Rename file dalam arsip
$zip->renameName('readme.txt', 'README.md');
$zip->renameIndex(0, 'dokumen/laporan.pdf');

// Ganti konten file yang sudah ada
$zip->deleteName('config.json');
$zip->addFromString('config.json', json_encode(['versi' => '2.0'], JSON_PRETTY_PRINT));

$zip->close();

Enkripsi ZIP #

PHP 7.2+ mendukung enkripsi AES untuk file dalam arsip ZIP:

<?php
$zip = new ZipArchive();
$zip->open('rahasia.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE);

// Tambah file
$zip->addFromString('data_sensitif.json', json_encode(['token' => 'abc123', 'key' => 'secret']));
$zip->addFile('laporan_keuangan.pdf', 'laporan.pdf');

// Set enkripsi untuk SEMUA file yang sudah ditambahkan
$password = 'password-rahasia-yang-kuat';
$zip->setPassword($password);

// Enkripsi per file (ZIP_EM_AES_256 — AES 256-bit)
for ($i = 0; $i < $zip->numFiles; $i++) {
    $zip->setEncryptionIndex($i, ZipArchive::EM_AES_256);
}

// Atau enkripsi semua sekaligus
// $zip->setEncryptionName('data_sensitif.json', ZipArchive::EM_AES_256);

$zip->close();

// Buka ZIP terenkripsi
$zip->open('rahasia.zip');
$zip->setPassword($password);
$isi = $zip->getFromName('data_sensitif.json');
// Berhasil jika password benar

$zip->close();

// Konstanta enkripsi yang tersedia:
// ZipArchive::EM_NONE      — tidak dienkripsi
// ZipArchive::EM_TRAD_PKWARE — enkripsi tradisional (lemah, hindari)
// ZipArchive::EM_AES_128   — AES 128-bit
// ZipArchive::EM_AES_192   — AES 192-bit
// ZipArchive::EM_AES_256   — AES 256-bit (rekomendasi)

Streaming ZIP ke Browser #

Untuk file ZIP besar atau yang dibuat secara dinamis, stream langsung ke browser tanpa menyimpan ke disk terlebih dahulu:

<?php
// Stream ZIP ke browser menggunakan php://output
function streamZipKeBrowser(array $files, string $namaFile = 'download.zip'): void
{
    // Set header untuk download
    header('Content-Type: application/zip');
    header('Content-Disposition: attachment; filename="' . rawurlencode($namaFile) . '"');
    header('Cache-Control: no-cache, no-store, must-revalidate');
    header('Pragma: no-cache');

    // Buat ZIP ke output langsung
    $zip = new ZipArchive();
    $tmpFile = tempnam(sys_get_temp_dir(), 'zip_');

    $zip->open($tmpFile, ZipArchive::OVERWRITE);

    foreach ($files as $pathDalamZip => $konten) {
        if (is_string($konten) && file_exists($konten)) {
            // Konten adalah path file
            $zip->addFile($konten, $pathDalamZip);
        } else {
            // Konten adalah string
            $zip->addFromString($pathDalamZip, (string) $konten);
        }
    }

    $zip->close();

    // Stream file temp ke browser
    header('Content-Length: ' . filesize($tmpFile));
    readfile($tmpFile);

    // Bersihkan
    unlink($tmpFile);
    exit;
}

// Penggunaan
streamZipKeBrowser([
    'laporan/Q1_2024.csv'  => '/data/laporan_q1.csv',  // path ke file
    'laporan/Q2_2024.csv'  => '/data/laporan_q2.csv',
    'readme.txt'           => "Laporan tahunan 2024\nDibuat: " . date('Y-m-d'),
    'metadata.json'        => json_encode(['tahun' => 2024, 'total' => 1250000]),
], 'laporan_2024.zip');

Pola Export Data yang Umum #

<?php
// Export hasil query database sebagai ZIP berisi beberapa file
function exportDataKaryawan(PDO $pdo, string $tahun): string
{
    $zipPath = sys_get_temp_dir() . '/export_karyawan_' . $tahun . '_' . time() . '.zip';
    $zip     = new ZipArchive();
    $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);

    // File 1: Daftar karyawan (CSV)
    $stmt = $pdo->prepare("SELECT id, nama, email, departemen, gaji FROM karyawan WHERE tahun = ?");
    $stmt->execute([$tahun]);

    $csvKaryawan = "ID,Nama,Email,Departemen,Gaji\n";
    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        $csvKaryawan .= implode(',', array_map(fn($v) => '"' . str_replace('"', '""', $v) . '"', $row)) . "\n";
    }
    $zip->addFromString("karyawan_$tahun.csv", $csvKaryawan);

    // File 2: Ringkasan per departemen
    $stmt = $pdo->prepare("
        SELECT departemen, COUNT(*) as jumlah, AVG(gaji) as rata_gaji
        FROM karyawan WHERE tahun = ?
        GROUP BY departemen ORDER BY departemen
    ");
    $stmt->execute([$tahun]);
    $summary = $stmt->fetchAll(PDO::FETCH_ASSOC);

    $csvSummary = "Departemen,Jumlah Karyawan,Rata-rata Gaji\n";
    foreach ($summary as $row) {
        $csvSummary .= "\"{$row['departemen']}\",{$row['jumlah']}," . number_format($row['rata_gaji'], 0) . "\n";
    }
    $zip->addFromString("ringkasan_departemen_$tahun.csv", $csvSummary);

    // File 3: Metadata
    $zip->addFromString('info_export.json', json_encode([
        'tahun'       => $tahun,
        'total'       => count($summary),
        'diekspor_at' => date('Y-m-d H:i:s'),
        'versi'       => '1.0',
    ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));

    // File 4: README
    $zip->addFromString('README.txt', implode("\n", [
        "EXPORT DATA KARYAWAN $tahun",
        str_repeat('=', 30),
        "Dibuat: " . date('Y-m-d H:i:s'),
        "",
        "Isi arsip:",
        "- karyawan_$tahun.csv : Daftar lengkap karyawan",
        "- ringkasan_departemen_$tahun.csv : Ringkasan per departemen",
        "- info_export.json : Metadata export",
    ]));

    $zip->close();
    return $zipPath;
}

// Di controller
$zipPath = exportDataKaryawan($pdo, '2024');

// Kirim ke browser
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="data_karyawan_2024.zip"');
header('Content-Length: ' . filesize($zipPath));
header('Cache-Control: no-cache');
readfile($zipPath);
unlink($zipPath); // hapus file temp setelah dikirim
exit;

Anti-Pattern ZIP yang Sering Ditemui #

<?php
// ✗ Anti-pattern 1: tidak validasi konten sebelum ekstrak (zip bomb & path traversal)
$zip = new ZipArchive();
$zip->open($_FILES['upload']['tmp_name']);
$zip->extractTo('/var/www/uploads/'); // BAHAYA! path traversal dan zip bomb
$zip->close();

// ✓ Validasi ukuran dan path sebelum ekstrak (lihat fungsi ekstrakAman() di atas)

// ✗ Anti-pattern 2: tidak cek return value open()
$zip = new ZipArchive();
$zip->open('tidak_ada.zip'); // return false tapi tidak dicek
$zip->extractTo('/tmp');      // fatal error!

// ✓ Selalu cek return value
$hasil = $zip->open('file.zip');
if ($hasil !== true) {
    throw new \RuntimeException("Gagal membuka ZIP: $hasil");
}

// ✗ Anti-pattern 3: tidak hapus file temp setelah selesai
$tmp = tempnam(sys_get_temp_dir(), 'zip_');
$zip->open($tmp, ZipArchive::CREATE);
// ... proses ...
$zip->close();
// Lupa unlink($tmp) — file temp menumpuk di /tmp!

// ✓ Gunakan try/finally untuk cleanup yang terjamin
$tmp = tempnam(sys_get_temp_dir(), 'zip_');
try {
    $zip = new ZipArchive();
    $zip->open($tmp, ZipArchive::CREATE);
    // proses...
    $zip->close();
    readfile($tmp);
} finally {
    if (file_exists($tmp)) {
        unlink($tmp);
    }
}

// ✗ Anti-pattern 4: ZIP bomb — file kecil yang mengekstrak jadi sangat besar
// Cek rasio kompresi sebelum ekstrak
function cekZipBomb(string $zipPath, float $maxRasio = 10.0, int $maxUkuranMB = 100): void
{
    $zip = new ZipArchive();
    $zip->open($zipPath);

    $totalUkuranAsli = 0;
    $ukuranKompres   = filesize($zipPath);

    for ($i = 0; $i < $zip->numFiles; $i++) {
        $stat = $zip->statIndex($i);
        $totalUkuranAsli += $stat['size'];
    }

    $zip->close();

    $rasio = $ukuranKompres > 0 ? $totalUkuranAsli / $ukuranKompres : 0;

    if ($rasio > $maxRasio) {
        throw new \RuntimeException("Rasio kompresi mencurigakan: $rasio (maks $maxRasio)");
    }

    if ($totalUkuranAsli > $maxUkuranMB * 1024 * 1024) {
        throw new \RuntimeException("Total ukuran ekstrak terlalu besar: " . round($totalUkuranAsli / 1024 / 1024) . " MB");
    }
}

Ringkasan #

  • ZipArchive::open() mengembalikan true atau kode error integer — selalu cek dengan === true, bukan hanya truthy.
  • addFile($path, $namaInZip) untuk file dari disk; addFromString($nama, $konten) untuk konten dari memori. Keduanya tidak perlu menutup dan membuka ulang — cukup close() di akhir.
  • Tambahkan direktori rekursif dengan RecursiveIteratorIterator + RecursiveDirectoryIterator — lebih fleksibel dari loop manual dan bisa difilter berdasarkan ekstensi atau path.
  • Validasi konten ZIP sebelum ekstrak — cegah path traversal dengan memastikan semua path tidak mengandung .. dan hasil realpath masih dalam direktori tujuan. Cegah zip bomb dengan cek rasio kompresi.
  • Stream ZIP ke browser via file temp + readfile() + unlink() — hindari menyimpan ZIP besar di direktori web yang bisa diakses publik.
  • getFromName() dan getStream() untuk membaca konten file dalam ZIP tanpa mengekstrak ke disk — berguna untuk validasi atau pemrosesan langsung.
  • Enkripsi AES-256 dengan setEncryptionIndex($i, ZipArchive::EM_AES_256) — tersedia sejak PHP 7.2, selalu gunakan AES bukan enkripsi tradisional PKWARE yang lemah.
  • Gunakan try/finally untuk memastikan file temp selalu dihapus — bahkan jika terjadi exception di tengah proses.

← Sebelumnya: SPL
About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact