Compactar pasta em ZIP com PHP sem mandar o .env junto
Monte o ZIP com ZipArchive a partir de uma lista branca de extensões, gere fora da requisição e entregue por link autenticado que expira.
O cliente do escritório pede "me manda os documentos de setembro". Alguém escreve um script que compacta a pasta inteira e publica o link. Dois meses depois, outra pessoa baixa esse mesmo ZIP e encontra, além dos PDFs, o .env com a senha do banco e uma pasta backups com o dump completo. A resposta curta: com ZipArchive, adicione só arquivos que passam numa lista branca de extensões, dentro de uma pasta de origem fixa; gere o ZIP num processo separado da página; e entregue por um script que confere a sessão, com nome aleatório e prazo de validade.
O exemplo é o mesmo sistema de contabilidade que guarda documentos em /var/www/dados/clientes/cliente-42/2026/09 e precisa oferecer o pacote do mês para download.
Por que "compactar a pasta" vaza mais do que devia
Os scripts clássicos percorrem o diretório recursivamente e adicionam tudo o que encontram. Isso inclui arquivos ocultos (que começam com ponto e você não vê no explorador), arquivos temporários de edição, o ZIP gerado no mês anterior e qualquer coisa que alguém colocou ali "só por um dia". Também segue o que estiver no caminho: um link simbólico para outra pasta leva o conteúdo dela junto.
O segundo problema é onde o ZIP fica. Se ele é gravado na pasta pública com nome previsível, como documentos-setembro.zip, qualquer pessoa que adivinhar o nome baixa, sem login. E se é gerado dentro da requisição, uma pasta de 2 GB estoura o tempo limite da página e deixa um arquivo pela metade no disco.
Como montar o ZIP com lista branca
- Resolva a pasta de origem com
realpathe guarde esse caminho como referência. - Percorra com
RecursiveDirectoryIterator, pulando.e... - Ignore links simbólicos, arquivos ocultos e tudo cuja extensão não esteja na lista.
- Confira que o caminho real de cada arquivo continua dentro da origem.
- Adicione com um nome relativo, para o ZIP não carregar o caminho completo do servidor.
- Feche o ZIP e confira o retorno de
close(), que é quando a gravação acontece de fato.
<?php
function gerarZip(string $origem, string $destinoZip): int
{
$raiz = realpath($origem);
if ($raiz === false || !is_dir($raiz)) {
throw new RuntimeException('Pasta de origem inexistente.');
}
$permitidas = ['pdf', 'jpg', 'jpeg', 'png', 'xlsx', 'xml'];
$zip = new ZipArchive();
if ($zip->open($destinoZip, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Não foi possível criar o ZIP.');
}
$itens = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($raiz, FilesystemIterator::SKIP_DOTS)
);
$total = 0;
foreach ($itens as $arquivo) {
if ($arquivo->isLink() || !$arquivo->isFile()) {
continue;
}
if (str_starts_with($arquivo->getFilename(), '.')) {
continue;
}
if (!in_array(strtolower($arquivo->getExtension()), $permitidas, true)) {
continue;
}
$real = $arquivo->getRealPath();
if ($real === false || !str_starts_with($real, $raiz . '/')) {
continue;
}
$zip->addFile($real, substr($real, strlen($raiz) + 1));
$total++;
}
if (!$zip->close()) {
throw new RuntimeException('Falha ao gravar o ZIP.');
}
return $total;
}
A lista branca é o coração do código. Uma lista negra ("tudo menos .env") falha no dia em que aparecer config.php.bak, chave.pem ou dump.sql. Com a lista branca, um tipo novo de arquivo fica de fora até alguém decidir, conscientemente, que ele pode sair. O nome relativo no segundo argumento de addFile faz o ZIP abrir com 2026/09/nota-123.pdf, e não com var/www/dados/..., que entregaria a estrutura do servidor.
Um detalhe do addFile
O addFile não lê o arquivo na hora; ele anota o caminho, e a leitura acontece no close(). Por isso o erro de um arquivo apagado no meio do caminho, ou de disco cheio, aparece no fechamento. Se você não confere o retorno de close(), entrega um ZIP corrompido e só descobre quando o cliente reclama.
Como gerar fora da requisição
O botão "baixar documentos de setembro" não deveria compactar nada. Ele registra um pedido numa tabela e responde "estamos preparando, você recebe um aviso". Um processo em segundo plano, disparado por cron ou por um worker de fila, faz o trabalho pesado:
<?php
// bin/gerar-pacote.php, executado pelo worker: php bin/gerar-pacote.php 42 2026-09
[$_, $clienteId, $mes] = $argv;
$clienteId = (int) $clienteId;
if (!preg_match('/^[0-9]{4}-[0-9]{2}$/', $mes)) {
exit(1);
}
$origem = '/var/www/dados/clientes/cliente-' . $clienteId . '/' . str_replace('-', '/', $mes);
$token = bin2hex(random_bytes(16));
$destino = '/var/www/dados/pacotes/' . $token . '.zip';
$total = gerarZip($origem, $destino);
// grave $token, $clienteId, $total e a validade (agora + 24 horas) na tabela de pacotes
Rodando no terminal, o script não tem o limite de tempo de uma página web, e uma falha não deixa o usuário olhando para um carregamento infinito. O nome do arquivo é aleatório, com 32 caracteres hexadecimais, e fica fora da pasta pública.
Como entregar o ZIP só para quem pode
<?php
// baixar-pacote.php?t=...
require __DIR__ . '/bootstrap.php';
$conta = contaLogada($pdo);
$token = (string) ($_GET['t'] ?? '');
if (!$conta || !preg_match('/^[a-f0-9]{32}$/', $token)) {
http_response_code(404);
exit;
}
$stmt = $pdo->prepare('SELECT 1 FROM pacotes WHERE token = ? AND cliente_id = ? AND expira_em > NOW()');
$stmt->execute([$token, $conta['cliente_id']]);
$caminho = '/var/www/dados/pacotes/' . $token . '.zip';
if (!$stmt->fetchColumn() || !is_file($caminho)) {
http_response_code(404);
exit;
}
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="documentos.zip"');
header('Content-Length: ' . filesize($caminho));
readfile($caminho);
Aqui a função contaLogada devolve também o cliente_id da conta, lido do banco. O token sozinho não basta: o script confere que o pacote pertence à conta logada e que ainda não expirou. E um cron apaga o que passou do prazo, para o disco não virar arquivo morto de pacotes que ninguém vai baixar de novo:
0 3 * * * find /var/www/dados/pacotes -name '*.zip' -mmin +1440 -delete
Como conferir o que entrou
Antes de liberar o recurso, gere um pacote de teste e liste o conteúdo sem extrair. Procure qualquer nome que comece com ponto, termine em .sql, .env, .pem ou .bak, ou que você não reconheça:
$ unzip -l /var/www/dados/pacotes/teste.zip
O caminho inverso, receber um ZIP e extrair sem que ele escreva fora da pasta, tem suas próprias armadilhas e está em descompactar ZIP em PHP sem escrever arquivo fora da pasta.
Erros comuns
- Compactar a pasta do projeto inteira. O pacote leva código,
.env, chaves e às vezes o histórico do Git, que guarda até segredos já apagados. - Usar lista negra de extensões. O primeiro arquivo com extensão que ninguém previu, como um
.bak, sai junto. - Gravar o ZIP na pasta pública com nome previsível. Qualquer pessoa baixa sem login, e buscadores podem indexar o link.
- Gerar dentro da requisição. Pastas grandes estouram o tempo limite e deixam arquivos parciais no disco.
- Ignorar o retorno de
close(). O ZIP corrompido é entregue como se estivesse pronto. - Nunca apagar os pacotes. O disco enche, e cópias de documentos sensíveis ficam guardadas sem motivo.
Checklist
- A origem é uma pasta fixa, resolvida com
realpath. - Só entram extensões de uma lista branca; ocultos e links simbólicos ficam de fora.
- Os nomes dentro do ZIP são relativos à origem.
- O retorno de
close()é verificado. - A geração roda em segundo plano, não na página.
- O arquivo tem nome aleatório e fica fora da pasta pública.
- O download confere sessão, dono do pacote e validade.
- Um cron apaga pacotes vencidos.
Pegue o último ZIP que o seu sistema gerou e rode unzip -l nele agora. Existe algum arquivo ali que você não mandaria por e-mail para o cliente?
Referência: http://alexandredisalvo.wordpress.com/2012/04/24/compactar-diretorio-via-php/