PHP e i formati di compressione
PHP offre tutto il necessario per lavorare con i formati di compressione, ma distribuito su più estensioni e con API nate in epoche diverse.
PHP gestisce la compressione attraverso un insieme di estensioni: zlib per Gzip e DEFLATE, bz2 per bzip2, zip per gli archivi ZIP tramite la classe ZipArchive e phar per gli archivi TAR tramite PharData. A queste si aggiungono le estensioni PECL per Brotli e Zstandard. In questo articolo vediamo come usarle in modo corretto, dalle funzioni in memoria alla compressione incrementale, dagli stream wrapper alla creazione e all'estrazione sicura degli archivi.
Verificare le estensioni disponibili
A differenza di altri linguaggi, in PHP il supporto alla compressione dipende da come è stato compilato l'interprete e da quali estensioni sono abilitate. Le estensioni zlib e phar sono quasi sempre presenti, mentre zip e bz2 su molte distribuzioni vanno installate separatamente (ad esempio con i pacchetti php8.3-zip e php8.3-bz2 su Debian e Ubuntu). Prima di tutto conviene verificare cosa è disponibile.
<?php
declare(strict_types=1);
$extensions = ['zlib', 'bz2', 'zip', 'phar', 'brotli', 'zstd'];
foreach ($extensions as $extension) {
$status = extension_loaded($extension) ? 'disponibile' : 'assente';
printf("%-8s %s\n", $extension, $status);
}
Da riga di comando lo stesso controllo si ottiene con php -m. Nelle applicazioni Laravel o Symfony è buona pratica dichiarare le estensioni necessarie nella sezione require del composer.json (ad esempio "ext-zip": "*"), così che Composer segnali subito un ambiente incompleto.
Le tre varianti di DEFLATE
L'estensione zlib espone tre coppie di funzioni, una per ciascun contenitore della famiglia DEFLATE. I nomi sono storicamente infelici e generano confusione, quindi vale la pena chiarirli:
gzdeflateegzinflateproducono e leggono DEFLATE raw (RFC 1951), senza intestazioni;gzcompressegzuncompressproducono e leggono il formato zlib (RFC 1950);gzencodeegzdecodeproducono e leggono il formato Gzip (RFC 1952), lo stesso dei file.gz.
Nonostante il prefisso gz, solo l'ultima coppia produce dati compatibili con l'utility gzip. Esistono inoltre zlib_encode e zlib_decode, che accettano il formato come parametro; zlib_decode riconosce automaticamente tutti e tre.
<?php
declare(strict_types=1);
$payload = json_encode([
'users' => array_map(
fn (int $i): array => ['id' => $i, 'name' => "user-{$i}", 'active' => $i % 2 === 0],
range(1, 1000)
),
], JSON_THROW_ON_ERROR);
$raw = gzdeflate($payload, 6);
$zlib = gzcompress($payload, 6);
$gzip = gzencode($payload, 6);
printf("Originale: %d byte\n", strlen($payload));
printf("Raw: %d byte\n", strlen($raw));
printf("Zlib: %d byte\n", strlen($zlib));
printf("Gzip: %d byte\n", strlen($gzip));
// Ogni funzione di decompressione accetta solo il proprio formato
var_dump(gzinflate($raw) === $payload);
var_dump(gzuncompress($zlib) === $payload);
var_dump(gzdecode($gzip) === $payload);
// zlib_decode rileva automaticamente il formato
var_dump(zlib_decode($gzip) === $payload);
Il secondo parametro è il livello di compressione, da 0 a 9, con -1 come default della libreria (equivalente a 6). In caso di dati non validi le funzioni di decompressione restituiscono false ed emettono un warning, invece di lanciare un'eccezione: è un comportamento da gestire esplicitamente, come vedremo più avanti.
Lavorare con i file Gzip
Per i file su disco l'estensione offre una famiglia di funzioni che ricalca quella dei file ordinari: gzopen, gzread, gzwrite, gzgets, gzeof e gzclose. Il livello di compressione si indica nella modalità di apertura, ad esempio wb9. In alternativa si può usare lo stream wrapper compress.zlib:// con le normali funzioni di filesystem, il che permette di riutilizzare codice esistente che lavora con fopen.
<?php
declare(strict_types=1);
/**
* Comprime un file in formato Gzip lavorando a blocchi.
*/
function gzipFile(string $source, string $destination, int $level = 6): void
{
$in = fopen($source, 'rb');
if ($in === false) {
throw new RuntimeException("Impossibile aprire {$source}");
}
$out = gzopen($destination, "wb{$level}");
if ($out === false) {
fclose($in);
throw new RuntimeException("Impossibile creare {$destination}");
}
try {
while (!feof($in)) {
$chunk = fread($in, 1024 * 1024);
if ($chunk === false) {
throw new RuntimeException('Errore di lettura');
}
if ($chunk !== '' && gzwrite($out, $chunk) === false) {
throw new RuntimeException('Errore di scrittura');
}
}
} finally {
fclose($in);
gzclose($out);
}
}
/**
* Conta le righe che contengono un testo in un file compresso, senza caricarlo in memoria.
*/
function countMatchingLines(string $gzipPath, string $needle): int
{
// Lo stream wrapper permette di usare le normali funzioni di filesystem
$file = new SplFileObject("compress.zlib://{$gzipPath}", 'rb');
$count = 0;
foreach ($file as $line) {
if (is_string($line) && str_contains($line, $needle)) {
$count++;
}
}
return $count;
}
gzipFile('storage/logs/laravel.log', 'storage/logs/laravel.log.gz', 9);
printf("Errori trovati: %d\n", countMatchingLines('storage/logs/laravel.log.gz', '.ERROR:'));
Compressione incrementale
Da PHP 7 sono disponibili le funzioni deflate_init, deflate_add, inflate_init e inflate_add, che permettono di comprimere e decomprimere un flusso a blocchi mantenendo un contesto tra una chiamata e l'altra. Sono lo strumento giusto quando i dati arrivano da una sorgente che non è un file, come una risposta HTTP in streaming o una query che restituisce milioni di righe.
<?php
declare(strict_types=1);
/**
* Esporta un insieme di righe in CSV compresso Gzip, comprimendo man mano.
*
* @param iterable<array<int, string|int|float>> $rows
*/
function exportCompressedCsv(iterable $rows, string $destination): void
{
$context = deflate_init(ZLIB_ENCODING_GZIP, ['level' => 6]);
$out = fopen($destination, 'wb');
$buffer = fopen('php://memory', 'w+b');
foreach ($rows as $row) {
// fputcsv gestisce correttamente virgolette e separatori
ftruncate($buffer, 0);
rewind($buffer);
fputcsv($buffer, $row, ',', '"', '');
rewind($buffer);
$line = stream_get_contents($buffer);
// ZLIB_NO_FLUSH lascia che il compressore accumuli dati per comprimere meglio
fwrite($out, deflate_add($context, $line, ZLIB_NO_FLUSH));
}
// ZLIB_FINISH emette i dati residui e il trailer Gzip
fwrite($out, deflate_add($context, '', ZLIB_FINISH));
fclose($buffer);
fclose($out);
}
/**
* Generatore che simula la lettura di molte righe da un database.
*
* @return Generator<int, array<int, string|int|float>>
*/
function readings(): Generator
{
yield ['id', 'sensor', 'value'];
for ($i = 1; $i <= 100_000; $i++) {
yield [$i, 'sensor-' . ($i % 50), 20 + ($i % 7) + 0.5];
}
}
exportCompressedCsv(readings(), 'readings.csv.gz');
echo 'Righe esportate: ', count(gzfile('readings.csv.gz')), PHP_EOL;
Le costanti di flush hanno lo stesso significato della libreria zlib in C: ZLIB_NO_FLUSH per l'uso normale, ZLIB_SYNC_FLUSH per forzare l'emissione dei dati accumulati mantenendo lo stream aperto (utile per i Server-Sent Events) e ZLIB_FINISH per chiudere il flusso.
Compressione dell'output HTTP
PHP può comprimere automaticamente l'output inviato al client tramite la direttiva zlib.output_compression nel php.ini, oppure con ob_start('ob_gzhandler'). In entrambi i casi PHP legge l'intestazione Accept-Encoding e comprime solo se il client lo supporta. Nella maggior parte delle installazioni moderne, tuttavia, questo compito spetta al web server (nginx con gzip on o il modulo Brotli), che lo svolge in modo più efficiente e lo applica anche agli asset statici. Abilitare la compressione sia in PHP sia nel web server è un errore comune: nel migliore dei casi spreca CPU, nel peggiore produce risposte compresse due volte.
<?php
declare(strict_types=1);
// Compressione esplicita di una risposta JSON, da usare solo se il web server non comprime già
$data = ['items' => array_fill(0, 2000, ['name' => 'item', 'price' => 9.99])];
$json = json_encode($data, JSON_THROW_ON_ERROR);
/**
* Verifica se il client accetta gzip con un valore di qualità maggiore di zero.
*/
function acceptsGzip(string $header): bool
{
foreach (explode(',', $header) as $part) {
$params = array_map('trim', explode(';', $part));
$name = strtolower(array_shift($params));
if ($name !== 'gzip' && $name !== '*') {
continue;
}
$quality = 1.0;
foreach ($params as $param) {
if (str_starts_with($param, 'q=')) {
$quality = (float) substr($param, 2);
}
}
return $quality > 0;
}
return false;
}
$acceptEncoding = $_SERVER['HTTP_ACCEPT_ENCODING'] ?? '';
header('Content-Type: application/json');
header('Vary: Accept-Encoding');
if (strlen($json) > 1024 && acceptsGzip($acceptEncoding)) {
$body = gzencode($json, 5);
header('Content-Encoding: gzip');
} else {
$body = $json;
}
header('Content-Length: ' . strlen($body));
echo $body;
Bzip2, Brotli e Zstandard
L'estensione bz2 offre bzcompress e bzdecompress per i dati in memoria e bzopen o lo stream wrapper compress.bzip2:// per i file. Bzip2 comprime meglio di Gzip i file di testo, ma è molto più lento ed è stato ampiamente superato da XZ e Zstandard.
Brotli e Zstandard sono disponibili tramite le estensioni PECL brotli e zstd, installabili con pecl install brotli e pecl install zstd. Entrambe forniscono funzioni in memoria, funzioni incrementali e uno stream wrapper.
<?php
declare(strict_types=1);
$text = str_repeat('{"level":"info","msg":"request handled","status":200}' . "\n", 5000);
if (extension_loaded('bz2')) {
$bz = bzcompress($text, 9);
printf("Bzip2: %d byte\n", strlen($bz));
}
if (extension_loaded('brotli')) {
// La qualità va da 0 a 11; la modalità testo aiuta con contenuti testuali
$br = brotli_compress($text, 11, BROTLI_TEXT);
printf("Brotli: %d byte (ok: %s)\n", strlen($br), brotli_uncompress($br) === $text ? 'sì' : 'no');
}
if (extension_loaded('zstd')) {
// Il livello va da 1 a 22, con default 3
$zs = zstd_compress($text, 10);
printf("Zstd: %d byte (ok: %s)\n", strlen($zs), zstd_uncompress($zs) === $text ? 'sì' : 'no');
}
Se l'installazione di estensioni PECL non è possibile, ad esempio su un hosting condiviso, si può ripiegare sui comandi di sistema tramite proc_open, ma è una soluzione da usare con cautela e mai con argomenti provenienti dall'utente.
Creare archivi ZIP con ZipArchive
La classe ZipArchive permette di creare, modificare e leggere archivi ZIP. Una caratteristica da conoscere: le modifiche non vengono scritte su disco a ogni chiamata, ma solo alla chiamata di close(). Per questo motivo gli errori di scrittura emergono lì, ed è lì che vanno controllati.
<?php
declare(strict_types=1);
final class ZipBuilder
{
// Estensioni già compresse da memorizzare senza ricomprimerle
private const STORED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'mp4', 'zip', 'gz', 'pdf'];
/**
* Crea un archivio ZIP con il contenuto di una directory.
*/
public static function fromDirectory(string $sourceDir, string $zipPath, ?string $password = null): int
{
$zip = new ZipArchive();
$result = $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($result !== true) {
throw new RuntimeException("Impossibile creare l'archivio (codice {$result})");
}
$sourceDir = rtrim(realpath($sourceDir) ?: throw new RuntimeException('Directory inesistente'), DIRECTORY_SEPARATOR);
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($sourceDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
$count = 0;
foreach ($iterator as $file) {
/** @var SplFileInfo $file */
$localName = str_replace(DIRECTORY_SEPARATOR, '/', substr($file->getPathname(), strlen($sourceDir) + 1));
if ($file->isDir()) {
$zip->addEmptyDir($localName);
continue;
}
$zip->addFile($file->getPathname(), $localName);
if (in_array(strtolower($file->getExtension()), self::STORED_EXTENSIONS, true)) {
$zip->setCompressionName($localName, ZipArchive::CM_STORE);
} else {
// Il terzo parametro è il livello di compressione DEFLATE
$zip->setCompressionName($localName, ZipArchive::CM_DEFLATE, 9);
}
if ($password !== null) {
// AES-256 è l'unica cifratura ZIP da considerare sicura
$zip->setEncryptionName($localName, ZipArchive::EM_AES_256, $password);
}
$count++;
}
if ($zip->close() === false) {
throw new RuntimeException('Errore durante la scrittura dell\'archivio: ' . $zip->getStatusString());
}
return $count;
}
}
$files = ZipBuilder::fromDirectory(__DIR__ . '/docs', __DIR__ . '/docs.zip');
printf("File archiviati: %d\n", $files);
Il metodo addFile mantiene aperto un descrittore per ogni file fino a close(), e con archivi di molte migliaia di file si può raggiungere il limite del sistema operativo. In quel caso conviene chiudere e riaprire l'archivio ogni qualche centinaio di file, oppure usare addFromString per i file piccoli.
Estrazione sicura degli archivi ZIP
Il metodo extractTo è comodo, ma estrarre archivi caricati dagli utenti richiede controlli aggiuntivi. Le versioni recenti di libzip normalizzano i percorsi con .., ma non conviene affidarsi a questo comportamento, e soprattutto nessuna protezione è prevista contro le decompression bomb. L'approccio robusto consiste nell'iterare sulle voci con statIndex, validarle e copiarle a blocchi tramite getStream, contando i byte realmente scritti.
<?php
declare(strict_types=1);
final class UnsafeArchiveException extends RuntimeException
{
}
final class SafeUnzipper
{
public function __construct(
private readonly int $maxEntries = 10_000,
private readonly int $maxTotalSize = 1024 ** 3,
private readonly float $maxRatio = 200.0,
) {
}
public function extract(string $zipPath, string $destination): void
{
$zip = new ZipArchive();
if (($code = $zip->open($zipPath, ZipArchive::RDONLY | ZipArchive::CHECKCONS)) !== true) {
throw new UnsafeArchiveException("Archivio non valido (codice {$code})");
}
try {
if ($zip->numFiles > $this->maxEntries) {
throw new UnsafeArchiveException("Troppe voci: {$zip->numFiles}");
}
if (!is_dir($destination) && !mkdir($destination, 0755, true)) {
throw new RuntimeException("Impossibile creare {$destination}");
}
$root = realpath($destination);
$written = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
$name = $stat['name'];
$target = $this->resolveTarget($root, $name);
if ($stat['comp_size'] > 0 && $stat['size'] / $stat['comp_size'] > $this->maxRatio) {
throw new UnsafeArchiveException("Rapporto di compressione sospetto: {$name}");
}
if (str_ends_with($name, '/')) {
is_dir($target) || mkdir($target, 0755, true);
continue;
}
is_dir(dirname($target)) || mkdir(dirname($target), 0755, true);
$written += $this->copyEntry($zip, $name, $target, $this->maxTotalSize - $written);
}
} finally {
$zip->close();
}
}
/**
* Calcola il percorso di destinazione rifiutando percorsi assoluti e risalite.
*/
private function resolveTarget(string $root, string $name): string
{
$normalized = str_replace('\\', '/', $name);
if ($normalized === '' || str_starts_with($normalized, '/') || preg_match('#^[a-zA-Z]:#', $normalized)) {
throw new UnsafeArchiveException("Percorso assoluto non ammesso: {$name}");
}
foreach (explode('/', $normalized) as $segment) {
if ($segment === '..') {
throw new UnsafeArchiveException("Percorso non sicuro: {$name}");
}
}
return $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $normalized);
}
/**
* Copia una voce a blocchi e restituisce i byte scritti.
*/
private function copyEntry(ZipArchive $zip, string $name, string $target, int $remaining): int
{
$in = $zip->getStream($name);
$out = fopen($target, 'wb');
if ($in === false || $out === false) {
throw new RuntimeException("Impossibile estrarre {$name}");
}
$written = 0;
try {
while (!feof($in)) {
$chunk = fread($in, 65536);
if ($chunk === false) {
throw new RuntimeException("Errore di lettura su {$name}");
}
$written += strlen($chunk);
// Contiamo i byte reali: le dimensioni nell'intestazione possono essere falsificate
if ($written > $remaining) {
throw new UnsafeArchiveException('Dimensione totale estratta oltre il limite');
}
fwrite($out, $chunk);
}
} finally {
fclose($in);
fclose($out);
}
return $written;
}
}
(new SafeUnzipper())->extract('upload.zip', __DIR__ . '/extracted');
Il flag ZipArchive::CHECKCONS chiede a libzip di verificare la coerenza strutturale dell'archivio all'apertura, intercettando molti file malformati prima ancora di iniziare l'estrazione.
Archivi TAR con PharData
PHP non ha un'estensione dedicata al formato TAR, ma la classe PharData dell'estensione phar è in grado di creare e leggere archivi TAR, anche compressi con Gzip o bzip2. Il flusso tipico prevede la creazione di un TAR non compresso e la successiva conversione con compress(), che genera un nuovo file con l'estensione appropriata.
<?php
declare(strict_types=1);
/**
* Crea un archivio tar.gz di una directory e restituisce il percorso del file compresso.
*/
function createTarGz(string $sourceDir, string $tarPath): string
{
foreach ([$tarPath, "{$tarPath}.gz"] as $existing) {
if (file_exists($existing)) {
unlink($existing);
}
}
$tar = new PharData($tarPath);
// Il secondo parametro è un'espressione regolare per filtrare i file da includere
$tar->buildFromDirectory($sourceDir, '/^(?!.*\/(\.git|node_modules)\/).*$/');
// compress crea project.tar.gz accanto al TAR originale
$compressed = $tar->compress(Phar::GZ);
unset($tar);
unlink($tarPath);
return $compressed->getPath();
}
/**
* Elenca il contenuto di un archivio TAR compresso.
*/
function listTar(string $archive): void
{
$phar = new PharData($archive);
foreach (new RecursiveIteratorIterator($phar) as $file) {
/** @var PharFileInfo $file */
printf("%-50s %10d byte\n", $file->getPathname(), $file->getSize());
}
}
$archive = createTarGz(__DIR__ . '/project', __DIR__ . '/project.tar');
listTar($archive);
Per l'estrazione esiste il metodo extractTo. Come per gli ZIP, gli archivi non fidati vanno prima ispezionati iterando sui file e verificandone percorsi e dimensioni; in alternativa, per archivi TAR molto grandi o in formati non supportati da PharData (come .tar.zst), è più efficiente affidarsi all'utility tar di sistema con le opzioni di sicurezza appropriate.
Gestire gli errori in modo coerente
Le funzioni di zlib segnalano gli errori restituendo false ed emettendo un warning: un approccio poco adatto al codice moderno. Un piccolo wrapper che converte gli errori in eccezioni e impone un limite all'output rende la decompressione di dati esterni sicura e prevedibile. Il secondo parametro di gzdecode, gzinflate e gzuncompress è proprio la dimensione massima dei dati decompressi.
<?php
declare(strict_types=1);
final class Compression
{
/**
* Decomprime dati Gzip o zlib con un limite sulla dimensione dell'output.
*/
public static function decode(string $data, int $maxLength = 10 * 1024 * 1024): string
{
set_error_handler(static function (int $severity, string $message): never {
throw new UnexpectedValueException("Decompressione fallita: {$message}");
});
try {
// Il limite blocca le decompression bomb prima che esauriscano la memoria
$result = zlib_decode($data, $maxLength);
} finally {
restore_error_handler();
}
if ($result === false) {
throw new UnexpectedValueException('Dati compressi non validi o oltre il limite');
}
return $result;
}
}
$bomb = gzencode(str_repeat("\0", 100 * 1024 * 1024), 9);
printf("Bomba compressa: %d byte\n", strlen($bomb));
try {
Compression::decode($bomb);
} catch (UnexpectedValueException $e) {
echo $e->getMessage(), PHP_EOL;
}
Conclusioni
PHP offre tutto il necessario per lavorare con i formati di compressione, ma distribuito su più estensioni e con API nate in epoche diverse. Conoscere la differenza tra gzdeflate, gzcompress e gzencode, preferire le funzioni incrementali per i flussi lunghi, controllare l'esito di ZipArchive::close() e, soprattutto, non estrarre mai archivi caricati dagli utenti senza validarne percorsi e dimensioni sono le basi per scrivere codice affidabile. Per la compressione delle risposte HTTP, nella maggior parte dei casi, la scelta migliore resta delegarla al web server.