ZIP Archives with ZipArchive

The zip extension (php8.5-zip) provides ZipArchive. open() returns true or an error code; addFile() and addFromString() stage entries, close() writes them, and statIndex(), getFromName() and extractTo() read them. The procedural zip_open() family is deprecated since PHP 8.0.

Building an export, listing it, extracting a hostile name, and zip://PHP
<?php
declare(strict_types=1);
file_put_contents($csv = __DIR__ . '/orders.csv', str_repeat("5001,BK-101,2,19.98\n", 500));
$zip = new ZipArchive();
$zip->open(__DIR__ . '/export.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE);  // true
$zip->addFile($csv, 'data/orders.csv');                  // stored under a new name
$zip->addFromString('../../evil.txt', 'zip slip');       // a hostile entry name
$zip->close();
$zip->open(__DIR__ . '/export.zip', ZipArchive::RDONLY);
for ($i = 0; $i < $zip->numFiles; $i++) {
  ['name' => $n, 'size' => $s, 'comp_size' => $c] = $zip->statIndex($i);
  echo "$n: $s -> $c bytes\n";
}
$zip->extractTo(__DIR__ . '/out');
echo implode(' ', scandir(__DIR__ . '/out')), "\n";
echo fgets(fopen('zip://' . __DIR__ . '/export.zip#data/orders.csv', 'r'));
Output
data/orders.csv: 10000 -> 62 bytes
../../evil.txt: 8 -> 8 bytes
. .. data evil.txt
5001,BK-101,2,19.98

extractTo() strips ../ and leading slashes, so evil.txt landed inside out/; if you unpack entries yourself with getStream(), apply the check from Paths and Metadata. zip:// reads one entry without extracting. To stream a large archive to the browser, use maennchen/zipstream-php 1,905 (https://github.com/maennchen/ZipStream-PHP 1,905 ).