Upload de Arquivos

Upload de Arquivos

Validar pelo conteúdo e não pelo que o navegador declara, gerar o nome no servidor e organizar o destino. Com a demonstração de por que a validação de MIME não fecha o caso sozinha e o que fazer quando o arquivo grava mas o registro no banco acaba falhando.
PHP

• • 34 min de leitura

Upload de arquivos parece simples — e na superfície é. Mas quando você considera segurança, validação real do tipo do arquivo, limites de tamanho, nomes de arquivo maliciosos, armazenamento organizado e suporte a múltiplos arquivos, o assunto revela profundidade considerável. Neste artigo construímos um sistema de upload robusto do zero.

Como o upload funciona

Quando um formulário com enctype="multipart/form-data" é submetido com arquivos, o PHP recebe os dados na superglobal $_FILES. Cada arquivo chega como um array com cinco campos:

<?php
// $_FILES['arquivo'] contém:
[
    'name'     => 'foto.jpg',           // Nome original no computador do usuário
    'type'     => 'image/jpeg',         // MIME type declarado pelo navegador (NÃO confie nisso)
    'tmp_name' => '/tmp/php8HgZ3x',     // Caminho temporário onde o PHP salvou
    'error'    => UPLOAD_ERR_OK,        // Código de erro (0 = sem erro)
    'size'     => 245678,               // Tamanho em bytes
]

Validação completa

<?php
declare(strict_types=1);

class ValidadorDeUpload
{
    private array $erros = [];

    private const TIPOS_PERMITIDOS = [
        'image/jpeg' => ['jpg', 'jpeg'],
        'image/png'  => ['png'],
        'image/webp' => ['webp'],
        'image/gif'  => ['gif'],
    ];

    private const TAMANHO_MAXIMO = 5 * 1024 * 1024; // 5MB

    public function validar(array $arquivo): bool
    {
        $this->erros = [];

        // 1. Verificar código de erro do PHP
        if ($arquivo['error'] !== UPLOAD_ERR_OK) {
            $this->erros[] = match ($arquivo['error']) {
                UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'Arquivo excede o tamanho máximo permitido.',
                UPLOAD_ERR_PARTIAL   => 'O arquivo foi enviado parcialmente.',
                UPLOAD_ERR_NO_FILE   => 'Nenhum arquivo foi selecionado.',
                // Estes três não são culpa de quem enviou: são defeito de
                // configuração do servidor. Vale separar, porque um
                // "erro desconhecido" genérico faz a equipe procurar no
                // lugar errado — e estes precisam ir para o log, não para a tela.
                UPLOAD_ERR_NO_TMP_DIR => 'Servidor sem diretório temporário configurado.',
                UPLOAD_ERR_CANT_WRITE => 'Servidor não conseguiu gravar em disco.',
                UPLOAD_ERR_EXTENSION  => 'Uma extensão do PHP interrompeu o upload.',
                default              => 'Erro desconhecido no upload.',
            };
            return false;
        }

        // 2. Verificar se é um upload legítimo (não um arquivo local do servidor)
        if (!is_uploaded_file($arquivo['tmp_name'])) {
            $this->erros[] = 'Arquivo inválido.';
            return false;
        }

        // 3. Verificar tamanho
        if ($arquivo['size'] > self::TAMANHO_MAXIMO) {
            $this->erros[] = 'Arquivo excede 5MB.';
            return false;
        }

        // 4. Verificar MIME type REAL com finfo (não confiar no $_FILES['type'])
        $finfo    = new finfo(FILEINFO_MIME_TYPE);
        $mimeReal = $finfo->file($arquivo['tmp_name']);

        if (!array_key_exists($mimeReal, self::TIPOS_PERMITIDOS)) {
            $this->erros[] = "Tipo de arquivo não permitido: {$mimeReal}";
            return false;
        }

        // 5. Verificar extensão contra o MIME real (dupla verificação)
        $extensao = strtolower(pathinfo($arquivo['name'], PATHINFO_EXTENSION));
        if (!in_array($extensao, self::TIPOS_PERMITIDOS[$mimeReal], strict: true)) {
            $this->erros[] = 'Extensão não corresponde ao tipo do arquivo.';
            return false;
        }

        return true;
    }

    public function erros(): array { return $this->erros; }
}

Armazenamento seguro

<?php
declare(strict_types=1);

class ServicoDeUpload
{
    public function __construct(
        private readonly string $diretorioBase,
        private readonly ValidadorDeUpload $validador,
    ) {
        if (!is_dir($this->diretorioBase)) {
            mkdir($this->diretorioBase, 0755, recursive: true);
            $this->protegerDiretorio($this->diretorioBase);
        }
    }

    /**
     * Validar MIME e extensão NÃO impede que o arquivo carregue código.
     * Um GIF de algumas dezenas de bytes com "<?php ... ?>" colado no fim
     * continua sendo image/gif para o finfo, continua sendo imagem válida
     * para o getimagesize(), e passa inteiro pela validação da classe
     * anterior. O que decide o desfecho não é a validação, é onde o
     * arquivo vai parar: se aquele diretório for interpretado pelo servidor,
     * o código roda ao ser acessado pela URL.
     *
     * O lugar certo para os uploads é FORA do document root, entregues por
     * um script que lê o arquivo e devolve o Content-Type. Quando isso não
     * for possível, negue a execução no próprio diretório.
     */
    private function protegerDiretorio(string $diretorio): void
    {
        // Vale para Apache com AllowOverride ligado. Em nginx não existe
        // .htaccess: a mesma regra vai no bloco server da configuração.
        $regras = <<<'CONF'
            # Nada aqui dentro é executável.
            php_flag engine off
            RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
            RemoveType .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
            Options -Indexes -ExecCGI
            CONF;

        file_put_contents($diretorio . '/.htaccess', $regras . "\n");
    }

    public function salvar(array $arquivo, string $subdiretorio = ''): string
    {
        if (!$this->validador->validar($arquivo)) {
            throw new InvalidArgumentException(
                implode(', ', $this->validador->erros())
            );
        }

        // NUNCA use o nome original do usuário — pode conter ../../../etc/passwd
        $extensao    = strtolower(pathinfo($arquivo['name'], PATHINFO_EXTENSION));
        $nomeSeguro  = bin2hex(random_bytes(16)) . '.' . $extensao; // Nome aleatório

        $diretorio = rtrim($this->diretorioBase . '/' . ltrim($subdiretorio, '/'), '/');
        if (!is_dir($diretorio)) {
            mkdir($diretorio, 0755, recursive: true);
        }

        $destino = $diretorio . '/' . $nomeSeguro;

        // move_uploaded_file: move o arquivo temporário para o destino final
        // Mais seguro que rename() pois verifica se é um upload legítimo
        if (!move_uploaded_file($arquivo['tmp_name'], $destino)) {
            throw new RuntimeException('Falha ao salvar o arquivo.');
        }

        // Retorna o caminho relativo ao diretorioBase
        return ltrim($subdiretorio . '/' . $nomeSeguro, '/');
    }
}

// Uso:
$servico = new ServicoDeUpload(
    diretorioBase: __DIR__ . '/storage/uploads',
    validador:     new ValidadorDeUpload(),
);

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['foto'])) {
    try {
        $caminho = $servico->salvar($_FILES['foto'], 'fotos/perfil');
        echo "Arquivo salvo em: {$caminho}";
    } catch (InvalidArgumentException $e) {
        echo "Erro de validação: " . $e->getMessage();
    }
}

Upload múltiplo

<!-- name="fotos[]" permite enviar múltiplos arquivos -->
<input type="file" name="fotos[]" multiple accept="image/*">
<?php

// $_FILES['fotos'] com múltiplos arquivos chega num formato diferente
// Precisamos reorganizar para iterar normalmente

function reorganizarArquivos(array $arquivos): array
{
    $resultado = [];
    foreach ($arquivos['name'] as $i => $nome) {
        $resultado[] = [
            'name'     => $nome,
            'type'     => $arquivos['type'][$i],
            'tmp_name' => $arquivos['tmp_name'][$i],
            'error'    => $arquivos['error'][$i],
            'size'     => $arquivos['size'][$i],
        ];
    }
    return $resultado;
}

if (isset($_FILES['fotos'])) {
    $arquivos = reorganizarArquivos($_FILES['fotos']);
    $salvos   = [];
    $erros    = [];

    foreach ($arquivos as $i => $arquivo) {
        try {
            $salvos[] = $servico->salvar($arquivo, 'galeria');
        } catch (InvalidArgumentException $e) {
            $erros[] = "Arquivo " . ($i + 1) . ": " . $e->getMessage();
        }
    }
}

Upload é a única funcionalidade em que o usuário coloca um arquivo escolhido por ele dentro do seu servidor, e é por isso que a lista de cuidados é tão longa. Trocar o nome original por um gerado no servidor resolve travessia de diretório e colisão de nomes de uma vez; conferir o tipo pelos bytes, com finfo, vale mais do que qualquer coisa que o navegador declare — o accept do formulário é conveniência visual, não barreira.

Ainda assim, nenhuma validação fecha o caso sozinha: um GIF válido pode carregar código colado no fim e continuar sendo um GIF para toda verificação de conteúdo. O que impede o estrago não é a checagem, é o destino — arquivo de usuário fora do diretório público, ou ao menos num diretório que o servidor se recuse a interpretar. E lembre que gravar em disco não participa da transação do banco: escolha de que lado prefere errar.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Implemente upload de avatar de usuário: salve o caminho no banco, exiba no perfil, limite a 2MB e aceite apenas JPEG, PNG e WebP.

Ver resposta

✓ Resposta: Três defesas sustentam o upload, e nenhuma delas é o accept do formulário: o tipo vem do finfo (bytes), o nome é gerado pelo servidor, e o diretório de destino fica fora do public/. Validar por $_FILES['type'] ou pela extensão do nome é como conferir identidade perguntando o nome — é exatamente assim que upload de foto vira shell remoto.

<?php

// ============ schema (trecho) ============
// ALTER TABLE usuarios
//     ADD COLUMN avatar_arquivo       VARCHAR(64) NULL AFTER email,
//     ADD COLUMN avatar_atualizado_em DATETIME    NULL;
//
// Guarda-se só o NOME do arquivo, não o caminho absoluto nem a URL: o dia que
// o storage mudar de lugar, é uma linha de configuração — não um UPDATE em
// 40 mil linhas.

// ---------------------------------------- src/Upload/FalhaNoUpload.php
declare(strict_types=1);

namespace App\Upload;

use RuntimeException;

final class FalhaNoUpload extends RuntimeException
{
    public static function doCodigoPhp(int $codigo): self
    {
        // As mensagens do PHP são numéricas; traduzi-las aqui evita espalhar
        // `if ($erro === 1)` pelo controller.
        $mensagens = [
            UPLOAD_ERR_INI_SIZE   => 'Arquivo maior que o upload_max_filesize do servidor.',
            UPLOAD_ERR_FORM_SIZE  => 'Arquivo maior que o limite declarado no formulário.',
            UPLOAD_ERR_PARTIAL    => 'O envio foi interrompido antes de terminar.',
            UPLOAD_ERR_NO_FILE    => 'Nenhum arquivo foi enviado.',
            UPLOAD_ERR_NO_TMP_DIR => 'O servidor está sem diretório temporário.',
            UPLOAD_ERR_CANT_WRITE => 'O servidor não conseguiu gravar o arquivo.',
            UPLOAD_ERR_EXTENSION  => 'Uma extensão do PHP bloqueou este upload.',
        ];

        return new self($mensagens[$codigo] ?? "Falha desconhecida no upload ({$codigo}).");
    }
}

// ---------------------------------------- src/Upload/ServicoAvatar.php
declare(strict_types=1);

namespace App\Upload;

use PDO;
use finfo;

final class ServicoAvatar
{
    private const TAMANHO_MAXIMO = 2 * 1024 * 1024;   // 2 MB

    /**
     * MIME real → extensão. A chave é o que o finfo lê dos BYTES do arquivo,
     * não o que o navegador declarou em $_FILES['type'] — esse campo é texto
     * enviado pelo cliente e mente sempre que o cliente quiser.
     */
    private const TIPOS_ACEITOS = [
        'image/jpeg' => 'jpg',
        'image/png'  => 'png',
        'image/webp' => 'webp',
    ];

    public function __construct(
        private readonly PDO $pdo,
        /** Diretório FORA do public/ — nada aqui pode ser executado pelo servidor web. */
        private readonly string $diretorio,
    ) {}

    /**
     * @param array{name:string,type:string,tmp_name:string,error:int,size:int} $arquivo
     * @return string nome do arquivo gravado
     */
    public function trocar(int $usuarioId, array $arquivo): string
    {
        if ($arquivo['error'] !== UPLOAD_ERR_OK) {
            throw FalhaNoUpload::doCodigoPhp($arquivo['error']);
        }

        // Sem esta linha, um caminho forjado em $_POST poderia convencer o
        // serviço a "mover" /etc/passwd para dentro do diretório de avatares.
        if (!is_uploaded_file($arquivo['tmp_name'])) {
            throw new FalhaNoUpload('A origem não é um upload HTTP.');
        }

        if ($arquivo['size'] > self::TAMANHO_MAXIMO) {
            throw new FalhaNoUpload(sprintf(
                'O avatar tem %s — o limite é 2 MB.',
                number_format($arquivo['size'] / 1048576, 1, ',', '.') . ' MB',
            ));
        }

        $mime = (new finfo(FILEINFO_MIME_TYPE))->file($arquivo['tmp_name']);

        if (!is_string($mime) || !isset(self::TIPOS_ACEITOS[$mime])) {
            throw new FalhaNoUpload('Formato não aceito. Envie JPEG, PNG ou WebP.');
        }

        // O finfo acerta o MIME de um PNG com payload PHP colado no fim —
        // ele continua sendo um PNG válido. Quem impede o estrago é o
        // diretório sem execução, mais o nome gerado abaixo.
        if (@getimagesize($arquivo['tmp_name']) === false) {
            throw new FalhaNoUpload('O arquivo não é uma imagem legível.');
        }

        // Nome novo e aleatório. O nome original é dado do usuário e pode
        // trazer ../, byte nulo ou o clássico "foto.php.jpg".
        $nome    = bin2hex(random_bytes(16)) . '.' . self::TIPOS_ACEITOS[$mime];
        $destino = $this->diretorio . '/' . $nome;

        if (!move_uploaded_file($arquivo['tmp_name'], $destino)) {
            throw new FalhaNoUpload('Não foi possível gravar o avatar.');
        }

        chmod($destino, 0644);

        $anterior = $this->arquivoAtual($usuarioId);

        $stmt = $this->pdo->prepare(
            'UPDATE usuarios
                SET avatar_arquivo = :arquivo, avatar_atualizado_em = NOW()
              WHERE id = :id'
        );
        $stmt->execute([':arquivo' => $nome, ':id' => $usuarioId]);

        // Só apaga o antigo DEPOIS que o banco confirmou o novo. Na ordem
        // inversa, um UPDATE que falha deixa o usuário sem avatar nenhum.
        if ($anterior !== null && $anterior !== $nome) {
            @unlink($this->diretorio . '/' . $anterior);
        }

        return $nome;
    }

    public function arquivoAtual(int $usuarioId): ?string
    {
        $stmt = $this->pdo->prepare('SELECT avatar_arquivo FROM usuarios WHERE id = :id');
        $stmt->execute([':id' => $usuarioId]);

        $valor = $stmt->fetchColumn();

        return is_string($valor) && $valor !== '' ? $valor : null;
    }
}

// ---------------------------------------- public/avatar.php  (entrega)
declare(strict_types=1);

// O diretório está fora do public/, então a imagem é servida por rota.
// Vantagem: dá para checar permissão antes de entregar o byte.
$diretorio = __DIR__ . '/../storage/avatares';

// basename() corta qualquer ../ que venha na query string.
$pedido = basename((string) ($_GET['a'] ?? ''));

// Lista branca no formato do nome: 32 hexadecimais + extensão conhecida.
if (!preg_match('/^[0-9a-f]{32}\.(jpg|png|webp)$/', $pedido)) {
    http_response_code(404);
    exit;
}

$caminho = $diretorio . '/' . $pedido;

if (!is_file($caminho)) {
    http_response_code(404);
    exit;
}

$mime = (new finfo(FILEINFO_MIME_TYPE))->file($caminho);

header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($caminho));
// O nome é aleatório e imutável: pode cachear à vontade.
header('Cache-Control: public, max-age=31536000, immutable');
// Impede o navegador de "adivinhar" que aquilo é HTML e renderizar.
header('X-Content-Type-Options: nosniff');

readfile($caminho);

// ---------------------------------------- public/perfil.php  (controller)
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use App\Upload\FalhaNoUpload;
use App\Upload\ServicoAvatar;

session_start();

$pdo     = require __DIR__ . '/../config/pdo.php';
$servico = new ServicoAvatar($pdo, __DIR__ . '/../storage/avatares');

$usuarioId = (int) ($_SESSION['usuario_id'] ?? 0);
$erro      = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST' && $usuarioId > 0) {
    try {
        $servico->trocar($usuarioId, $_FILES['avatar'] ?? ['error' => UPLOAD_ERR_NO_FILE]);

        // PRG: redireciona depois do POST para o F5 não reenviar o arquivo.
        header('Location: /perfil.php?ok=1', true, 303);
        exit;
    } catch (FalhaNoUpload $e) {
        $erro = $e->getMessage();
    }
}

$avatar = $servico->arquivoAtual($usuarioId);

require __DIR__ . '/../templates/perfil.php';

// ---------------------------------------- templates/perfil.php  (view)
?>
<h1>Meu perfil</h1>

<img src="/avatar.php?a=<?= htmlspecialchars($avatar ?? '', ENT_QUOTES, 'UTF-8') ?>"
     alt="Foto do perfil" width="120" height="120">

<?php if ($erro !== null): ?>
    <p class="erro"><?= htmlspecialchars($erro, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>

<form method="post" enctype="multipart/form-data">
    <!-- MAX_FILE_SIZE é uma dica ao navegador, nunca uma defesa: economiza a
         espera de subir 40 MB para só então ouvir "grande demais". -->
    <input type="hidden" name="MAX_FILE_SIZE" value="2097152">

    <label for="avatar">Novo avatar (JPEG, PNG ou WebP, até 2 MB)</label>
    <input type="file" id="avatar" name="avatar" required
           accept="image/jpeg,image/png,image/webp">

    <button type="submit">Enviar</button>
</form>

Exercício 2

Adicione redimensionamento de imagem usando a extensão GD: após o upload, crie uma versão thumbnail (150x150) e uma versão média (800x600) mantendo proporção.

Ver resposta

✓ Resposta: Dois detalhes separam o resultado bonito do borrado. imagecopyresampled interpola os pixels vizinhos; imagecopyresized apenas descarta pixels — é mais rápido e o resultado é serrilhado. E "150x150 mantendo proporção" é contraditório: ou se recorta o centro (o que este código faz) ou sobram tarjas. O que nunca se faz é esticar para o quadrado.

<?php

// ---------------------------------------- src/Imagem/Redimensionador.php
declare(strict_types=1);

namespace App\Imagem;

use GdImage;
use RuntimeException;

final class Redimensionador
{
    /**
     * A GD trabalha descomprimida: ~4 bytes por pixel, independente do
     * tamanho do ARQUIVO. Um PNG de 300 KB com 12000x12000 vira 576 MB de RAM
     * e derruba o processo antes de qualquer redimensionamento — por isso a
     * checagem vem de getimagesize(), antes de abrir.
     */
    private const PIXELS_MAXIMOS = 40_000_000;

    public function __construct(private readonly int $qualidade = 82) {}

    public function abrir(string $caminho): GdImage
    {
        $info = @getimagesize($caminho);

        if ($info === false) {
            throw new RuntimeException('Arquivo não é uma imagem legível.');
        }

        [$largura, $altura] = $info;

        if ($largura * $altura > self::PIXELS_MAXIMOS) {
            throw new RuntimeException(
                "Imagem grande demais: {$largura}x{$altura} pixels."
            );
        }

        $imagem = match ($info[2]) {
            IMAGETYPE_JPEG => imagecreatefromjpeg($caminho),
            IMAGETYPE_PNG  => imagecreatefrompng($caminho),
            IMAGETYPE_WEBP => imagecreatefromwebp($caminho),
            default        => throw new RuntimeException('Formato não suportado.'),
        };

        if ($imagem === false) {
            throw new RuntimeException('A GD não conseguiu decodificar a imagem.');
        }

        // PNG de 8 bits vem com paleta; sem converter, o resample devolve
        // uma imagem manchada. Em truecolor o cálculo de cor é exato.
        if (!imageistruecolor($imagem)) {
            imagepalettetotruecolor($imagem);
        }

        return $info[2] === IMAGETYPE_JPEG
            ? $this->corrigirOrientacao($imagem, $caminho)
            : $imagem;
    }

    /** Cabe dentro da caixa, sem distorcer e sem cortar. Nunca amplia. */
    public function conterEm(GdImage $origem, int $larguraMax, int $alturaMax): GdImage
    {
        $largura = imagesx($origem);
        $altura  = imagesy($origem);

        // min() com 1.0: ampliar uma foto pequena só produz borrão.
        $escala = min($larguraMax / $largura, $alturaMax / $altura, 1.0);

        $novaLargura = max(1, (int) round($largura * $escala));
        $novaAltura  = max(1, (int) round($altura  * $escala));

        $destino = $this->telaTransparente($novaLargura, $novaAltura);

        imagecopyresampled(
            $destino, $origem,
            0, 0, 0, 0,
            $novaLargura, $novaAltura, $largura, $altura,
        );

        return $destino;
    }

    /** Quadrado exato: recorta o centro e depois reduz. Não distorce. */
    public function recortarQuadrado(GdImage $origem, int $lado): GdImage
    {
        $largura = imagesx($origem);
        $altura  = imagesy($origem);
        $corte   = min($largura, $altura);

        // intdiv() em vez de /2: coordenada de pixel é inteiro.
        $x = intdiv($largura - $corte, 2);
        $y = intdiv($altura  - $corte, 2);

        $destino = $this->telaTransparente($lado, $lado);

        imagecopyresampled(
            $destino, $origem,
            0, 0, $x, $y,
            $lado, $lado, $corte, $corte,
        );

        return $destino;
    }

    public function salvar(GdImage $imagem, string $caminho, string $formato): void
    {
        $ok = match ($formato) {
            'jpg', 'jpeg' => imagejpeg($imagem, $caminho, $this->qualidade),
            'png'         => imagepng($imagem, $caminho, 6),
            'webp'        => imagewebp($imagem, $caminho, $this->qualidade),
            default       => throw new RuntimeException("Formato inválido: {$formato}"),
        };

        if (!$ok) {
            throw new RuntimeException("Falha ao gravar {$caminho}");
        }
    }

    private function telaTransparente(int $largura, int $altura): GdImage
    {
        $tela = imagecreatetruecolor($largura, $altura);

        // A ordem importa: desliga a mistura, pinta o fundo transparente e
        // só então liga o salvamento do canal alfa. Fora dessa ordem, PNG e
        // WebP com transparência saem com fundo preto.
        imagealphablending($tela, false);
        imagesavealpha($tela, true);
        imagefill($tela, 0, 0, imagecolorallocatealpha($tela, 0, 0, 0, 127));
        imagealphablending($tela, true);

        return $tela;
    }

    /** Foto de celular guarda a rotação no EXIF; sem isso ela sai deitada. */
    private function corrigirOrientacao(GdImage $imagem, string $caminho): GdImage
    {
        if (!function_exists('exif_read_data')) {
            return $imagem;
        }

        $exif = @exif_read_data($caminho);
        $giro = match ((int) ($exif['Orientation'] ?? 1)) {
            3       => 180,
            6       => -90,
            8       => 90,
            default => 0,
        };

        if ($giro === 0) {
            return $imagem;
        }

        $girada = imagerotate($imagem, $giro, 0);
        imagedestroy($imagem);

        return $girada;
    }
}

// ---------------------------------------- src/Imagem/GeradorDeVersoes.php
declare(strict_types=1);

namespace App\Imagem;

final class GeradorDeVersoes
{
    private const VERSOES = [
        'thumb' => ['modo' => 'quadrado', 'largura' => 150, 'altura' => 150],
        'media' => ['modo' => 'conter',   'largura' => 800, 'altura' => 600],
    ];

    public function __construct(
        private readonly Redimensionador $redimensionador,
        private readonly string $diretorio,
    ) {}

    /**
     * @return array<string, string> versão → nome do arquivo gerado
     */
    public function gerar(string $arquivoOriginal): array
    {
        $original = $this->redimensionador->abrir($this->diretorio . '/' . $arquivoOriginal);

        // O nome das versões deriva do original: thumb do "abc.jpg" é
        // "abc-thumb.webp". Nada de novo random — a versão tem de ser
        // recalculável a partir do original, e o navegador pode cachear.
        $base = pathinfo($arquivoOriginal, PATHINFO_FILENAME);
        $saida = [];

        try {
            foreach (self::VERSOES as $nome => $spec) {
                $versao = $spec['modo'] === 'quadrado'
                    ? $this->redimensionador->recortarQuadrado($original, $spec['largura'])
                    : $this->redimensionador->conterEm($original, $spec['largura'], $spec['altura']);

                // WebP em todas as versões derivadas: menor que JPEG na mesma
                // qualidade e com transparência, ao contrário do JPEG.
                $arquivo = "{$base}-{$nome}.webp";

                try {
                    $this->redimensionador->salvar($versao, $this->diretorio . '/' . $arquivo, 'webp');
                    $saida[$nome] = $arquivo;
                } finally {
                    imagedestroy($versao);
                }
            }
        } finally {
            // Sem destruir, cada upload deixa dezenas de MB presos até o fim
            // do request — e num worker de fila, até o fim do processo.
            imagedestroy($original);
        }

        return $saida;
    }
}

// ---------------------------------------- uso
declare(strict_types=1);

$gerador = new App\Imagem\GeradorDeVersoes(
    new App\Imagem\Redimensionador(qualidade: 82),
    __DIR__ . '/../storage/avatares',
);

$versoes = $gerador->gerar('7f3c9e1a2b4d6f8a0c2e4f6a8b0d2e4f.jpg');

// ['thumb' => '7f3c...-thumb.webp', 'media' => '7f3c...-media.webp']

Exercício 3

Implemente upload múltiplo de documentos PDF (máx. 10MB cada, máx. 5 arquivos por vez). Armazene os metadados (nome original, tamanho, data) no banco.

Ver resposta

✓ Resposta: O $_FILES de campo múltiplo vem transposto — cinco arrays paralelos, não uma lista de arquivos — e é aí que quase todo mundo tropeça. A armadilha mais cruel, porém, é o post_max_size: quando o envio inteiro o estoura, $_POST e $_FILES chegam vazios e nenhum UPLOAD_ERR_* é preenchido — a página parece só "não ter feito nada". O único vestígio é o CONTENT_LENGTH.

<?php

// ============ schema ============
// CREATE TABLE documentos (
//     id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
//     usuario_id    BIGINT UNSIGNED NOT NULL,
//     nome_original VARCHAR(255)    NOT NULL,
//     arquivo       VARCHAR(64)     NOT NULL UNIQUE,
//     tamanho_bytes INT UNSIGNED    NOT NULL,
//     enviado_em    DATETIME        NOT NULL,
//     CONSTRAINT fk_documentos_usuario FOREIGN KEY (usuario_id)
//         REFERENCES usuarios (id) ON DELETE RESTRICT
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
//
// ON DELETE RESTRICT, não CASCADE: apagar um usuário não pode varrer em
// silêncio os documentos que ele enviou — isso é decisão de negócio, tomada
// por um caso de uso que também remove os arquivos do disco.

// ---------------------------------------- src/Upload/ColecaoDeArquivos.php
declare(strict_types=1);

namespace App\Upload;

final class ColecaoDeArquivos
{
    /**
     * O $_FILES de um campo `name="docs[]"` vem TRANSPOSTO — cinco arrays
     * paralelos em vez de um array de arquivos:
     *
     *     $_FILES['docs']['name']     = ['a.pdf', 'b.pdf']
     *     $_FILES['docs']['tmp_name'] = ['/tmp/php1', '/tmp/php2']
     *
     * Este método devolve o formato que qualquer pessoa esperava:
     *
     *     [['name' => 'a.pdf', 'tmp_name' => '/tmp/php1', ...], [...]]
     *
     * @return list<array{name:string,type:string,tmp_name:string,error:int,size:int}>
     */
    public static function normalizar(array $campo): array
    {
        if (!is_array($campo['name'])) {
            return [$campo];
        }

        $arquivos = [];

        foreach (array_keys($campo['name']) as $i) {
            // Slot vazio de um input múltiplo não é erro — é campo em branco.
            if ($campo['error'][$i] === UPLOAD_ERR_NO_FILE) {
                continue;
            }

            $arquivos[] = [
                'name'     => (string) $campo['name'][$i],
                'type'     => (string) $campo['type'][$i],
                'tmp_name' => (string) $campo['tmp_name'][$i],
                'error'    => (int)    $campo['error'][$i],
                'size'     => (int)    $campo['size'][$i],
            ];
        }

        return $arquivos;
    }

    /**
     * Quando o POST estoura o post_max_size, o PHP descarta o corpo inteiro:
     * $_POST e $_FILES chegam VAZIOS e nenhum código de erro é preenchido.
     * O único vestígio é o Content-Length maior que o limite.
     */
    public static function postEstourouOLimite(): bool
    {
        if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $_POST !== [] || $_FILES !== []) {
            return false;
        }

        $limite = self::emBytes((string) ini_get('post_max_size'));

        return $limite > 0 && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $limite;
    }

    private static function emBytes(string $valor): int
    {
        $numero = (int) $valor;

        return match (strtolower(substr(trim($valor), -1))) {
            'g'     => $numero * 1024 ** 3,
            'm'     => $numero * 1024 ** 2,
            'k'     => $numero * 1024,
            default => $numero,
        };
    }
}

// ---------------------------------------- src/Upload/ServicoDeDocumentos.php
declare(strict_types=1);

namespace App\Upload;

use PDO;
use Throwable;
use finfo;

final class ServicoDeDocumentos
{
    private const TAMANHO_MAXIMO = 10 * 1024 * 1024;   // 10 MB por arquivo
    private const QUANTIDADE_MAXIMA = 5;

    public function __construct(
        private readonly PDO $pdo,
        private readonly string $diretorio,
    ) {}

    /**
     * @return array{gravados: list<string>, recusados: array<string, string>}
     */
    public function enviar(int $usuarioId, array $campo): array
    {
        if (ColecaoDeArquivos::postEstourouOLimite()) {
            throw new FalhaNoUpload(
                'O envio inteiro passou do limite do servidor (post_max_size).'
            );
        }

        $arquivos = ColecaoDeArquivos::normalizar($campo);

        if ($arquivos === []) {
            throw new FalhaNoUpload('Nenhum arquivo foi enviado.');
        }

        if (count($arquivos) > self::QUANTIDADE_MAXIMA) {
            throw new FalhaNoUpload(sprintf(
                'São no máximo %d arquivos por envio; vieram %d.',
                self::QUANTIDADE_MAXIMA,
                count($arquivos),
            ));
        }

        $validos    = [];
        $recusados  = [];

        foreach ($arquivos as $arquivo) {
            try {
                $validos[] = $this->validar($arquivo);
            } catch (FalhaNoUpload $e) {
                $recusados[$arquivo['name']] = $e->getMessage();
            }
        }

        return ['gravados' => $this->persistir($usuarioId, $validos), 'recusados' => $recusados];
    }

    /** @return array{nome:string, arquivo:string, origem:string, tamanho:int} */
    private function validar(array $arquivo): array
    {
        if ($arquivo['error'] !== UPLOAD_ERR_OK) {
            throw FalhaNoUpload::doCodigoPhp($arquivo['error']);
        }

        if (!is_uploaded_file($arquivo['tmp_name'])) {
            throw new FalhaNoUpload('A origem não é um upload HTTP.');
        }

        if ($arquivo['size'] > self::TAMANHO_MAXIMO) {
            throw new FalhaNoUpload('Passa de 10 MB.');
        }

        if ($arquivo['size'] === 0) {
            throw new FalhaNoUpload('O arquivo está vazio.');
        }

        $mime = (new finfo(FILEINFO_MIME_TYPE))->file($arquivo['tmp_name']);

        if ($mime !== 'application/pdf') {
            throw new FalhaNoUpload("Não é um PDF (detectado: {$mime}).");
        }

        // Cinturão e suspensório: o finfo já leu a assinatura, mas conferir
        // os cinco primeiros bytes custa nada e documenta a expectativa.
        $inicio = (string) file_get_contents($arquivo['tmp_name'], length: 5);

        if (!str_starts_with($inicio, '%PDF-')) {
            throw new FalhaNoUpload('O conteúdo não começa com a assinatura de PDF.');
        }

        return [
            // O nome ORIGINAL é metadado exibido ao usuário, nunca caminho.
            // Vai para o banco como texto e sai escapado na view.
            'nome'    => mb_substr($arquivo['name'], 0, 255),
            'arquivo' => bin2hex(random_bytes(16)) . '.pdf',
            'origem'  => $arquivo['tmp_name'],
            'tamanho' => $arquivo['size'],
        ];
    }

    /**
     * @param  list<array{nome:string,arquivo:string,origem:string,tamanho:int}> $validos
     * @return list<string>
     */
    private function persistir(int $usuarioId, array $validos): array
    {
        if ($validos === []) {
            return [];
        }

        $movidos = [];

        $this->pdo->beginTransaction();

        try {
            $stmt = $this->pdo->prepare(
                'INSERT INTO documentos
                        (usuario_id, nome_original, arquivo, tamanho_bytes, enviado_em)
                 VALUES (:usuario_id, :nome, :arquivo, :tamanho, NOW())'
            );

            foreach ($validos as $item) {
                $caminho = $this->diretorio . '/' . $item['arquivo'];

                if (!move_uploaded_file($item['origem'], $caminho)) {
                    throw new FalhaNoUpload("Falha ao gravar {$item['nome']}.");
                }

                $movidos[] = $caminho;

                $stmt->execute([
                    ':usuario_id' => $usuarioId,
                    ':nome'       => $item['nome'],
                    ':arquivo'    => $item['arquivo'],
                    ':tamanho'    => $item['tamanho'],
                ]);
            }

            $this->pdo->commit();
        } catch (Throwable $e) {
            $this->pdo->rollBack();

            // O rollback desfaz o banco, mas não o disco: os arquivos já
            // movidos viram lixo órfão se ninguém os apagar aqui.
            foreach ($movidos as $caminho) {
                @unlink($caminho);
            }

            throw $e;
        }

        return array_column($validos, 'arquivo');
    }
}

// ---------------------------------------- public/documentos.php
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use App\Upload\FalhaNoUpload;
use App\Upload\ServicoDeDocumentos;

session_start();

$pdo     = require __DIR__ . '/../config/pdo.php';
$servico = new ServicoDeDocumentos($pdo, __DIR__ . '/../storage/documentos');

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    try {
        $resultado = $servico->enviar(
            (int) $_SESSION['usuario_id'],
            $_FILES['documentos'] ?? ['name' => [], 'error' => []],
        );

        $_SESSION['flash'] = sprintf(
            '%d documento(s) enviado(s).%s',
            count($resultado['gravados']),
            $resultado['recusados'] === []
                ? ''
                : ' Recusados: ' . implode('; ', array_map(
                    static fn (string $nome, string $motivo): string => "{$nome} ({$motivo})",
                    array_keys($resultado['recusados']),
                    $resultado['recusados'],
                )),
        );
    } catch (FalhaNoUpload $e) {
        $_SESSION['flash'] = $e->getMessage();
    }

    header('Location: /documentos.php', true, 303);
    exit;
}

Exercício 4

Desafio: implemente armazenamento em dois backends intercambiáveis via interface StorageInterface: LocalStorage (disco) e S3Storage (simulado com arquivo de log). O ServicoDeUpload deve receber o backend por injeção de dependência.

Ver resposta

✓ Resposta: A injeção de dependência aqui não é cerimônia: é o que torna o upload testável sem disco e sem nuvem — veja o dublê no teste. O detalhe de projeto que decide se a abstração presta é o vocabulário: a interface fala em chave, não em caminho de arquivo. Interface que expõe $diretorio ou realpath() já nasceu acoplada ao disco, e o backend S3 vira gambiarra na hora de implementá-la.

<?php

// ---------------------------------------- src/Storage/StorageInterface.php
declare(strict_types=1);

namespace App\Storage;

/**
 * O vocabulário é "chave", não "caminho": no S3 não existe diretório, existe
 * prefixo no nome do objeto. Uma interface que falasse em `$diretorio` já
 * nasceria vazando o backend local para dentro do domínio.
 */
interface StorageInterface
{
    /** Move um upload temporário para o storage. Devolve a chave gravada. */
    public function guardar(string $origemTemporaria, string $chave): string;

    public function ler(string $chave): string;

    public function existe(string $chave): bool;

    public function remover(string $chave): bool;

    /** URL pública (ou assinada) para exibir o objeto. */
    public function url(string $chave): string;
}

// ---------------------------------------- src/Storage/FalhaDeStorage.php
declare(strict_types=1);

namespace App\Storage;

final class FalhaDeStorage extends \RuntimeException {}

// ---------------------------------------- src/Storage/LocalStorage.php
declare(strict_types=1);

namespace App\Storage;

final class LocalStorage implements StorageInterface
{
    public function __construct(
        private readonly string $raiz,
        private readonly string $urlBase = '/arquivo',
    ) {
        if (!is_dir($this->raiz) && !mkdir($this->raiz, 0755, true)) {
            throw new FalhaDeStorage("Não consegui criar {$this->raiz}");
        }
    }

    public function guardar(string $origemTemporaria, string $chave): string
    {
        $destino = $this->caminho($chave);

        // A chave pode ter prefixo ("avatares/2026/abc.jpg"): o diretório
        // correspondente precisa existir antes do move.
        $pasta = dirname($destino);

        if (!is_dir($pasta) && !mkdir($pasta, 0755, true)) {
            throw new FalhaDeStorage("Não consegui criar {$pasta}");
        }

        // is_uploaded_file distingue upload de arquivo comum: o serviço
        // também é usado em migração de dados, onde a origem não é HTTP.
        $ok = is_uploaded_file($origemTemporaria)
            ? move_uploaded_file($origemTemporaria, $destino)
            : rename($origemTemporaria, $destino);

        if (!$ok) {
            throw new FalhaDeStorage("Falha ao gravar a chave {$chave}");
        }

        chmod($destino, 0644);

        return $chave;
    }

    public function ler(string $chave): string
    {
        $conteudo = @file_get_contents($this->caminho($chave));

        if ($conteudo === false) {
            throw new FalhaDeStorage("Chave inexistente: {$chave}");
        }

        return $conteudo;
    }

    public function existe(string $chave): bool
    {
        return is_file($this->caminho($chave));
    }

    public function remover(string $chave): bool
    {
        return $this->existe($chave) && unlink($this->caminho($chave));
    }

    public function url(string $chave): string
    {
        return $this->urlBase . '/' . $chave;
    }

    private function caminho(string $chave): string
    {
        // A chave vem do domínio, mas o custo de conferir é uma regex:
        // um "../../.env" aqui daria leitura arbitrária de arquivo.
        if (!preg_match('#^[A-Za-z0-9._/-]+$#', $chave) || str_contains($chave, '..')) {
            throw new FalhaDeStorage("Chave inválida: {$chave}");
        }

        return $this->raiz . '/' . $chave;
    }
}

// ---------------------------------------- src/Storage/S3Storage.php
declare(strict_types=1);

namespace App\Storage;

/**
 * Simulação do S3 para o exercício: guarda os objetos numa pasta que faz as
 * vezes de bucket e registra cada operação num log, como faria o SDK.
 * Trocar por aws/aws-sdk-php é substituir o corpo dos cinco métodos — a
 * assinatura, que é o que o resto do sistema conhece, não muda.
 */
final class S3Storage implements StorageInterface
{
    public function __construct(
        private readonly string $bucket,
        private readonly string $regiao,
        private readonly string $pastaSimulada,
        private readonly string $arquivoDeLog,
    ) {}

    public function guardar(string $origemTemporaria, string $chave): string
    {
        $destino = $this->pastaSimulada . '/' . str_replace('/', '__', $chave);

        if (!copy($origemTemporaria, $destino)) {
            throw new FalhaDeStorage("PutObject falhou para {$chave}");
        }

        @unlink($origemTemporaria);

        $this->registrar('PutObject', $chave, filesize($destino) ?: 0);

        return $chave;
    }

    public function ler(string $chave): string
    {
        $this->registrar('GetObject', $chave);

        $conteudo = @file_get_contents($this->objeto($chave));

        if ($conteudo === false) {
            throw new FalhaDeStorage("NoSuchKey: {$chave}");
        }

        return $conteudo;
    }

    public function existe(string $chave): bool
    {
        $this->registrar('HeadObject', $chave);

        return is_file($this->objeto($chave));
    }

    public function remover(string $chave): bool
    {
        $this->registrar('DeleteObject', $chave);

        // O S3 devolve 204 mesmo para chave inexistente — DeleteObject é
        // idempotente. A simulação imita isso em vez de devolver false.
        @unlink($this->objeto($chave));

        return true;
    }

    public function url(string $chave): string
    {
        return sprintf('https://%s.s3.%s.amazonaws.com/%s',
            $this->bucket, $this->regiao, rawurlencode($chave));
    }

    private function objeto(string $chave): string
    {
        return $this->pastaSimulada . '/' . str_replace('/', '__', $chave);
    }

    private function registrar(string $operacao, string $chave, int $bytes = 0): void
    {
        $linha = sprintf(
            "%s  %-13s s3://%s/%s%s\n",
            (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
            $operacao,
            $this->bucket,
            $chave,
            $bytes > 0 ? "  ({$bytes} bytes)" : '',
        );

        file_put_contents($this->arquivoDeLog, $linha, FILE_APPEND | LOCK_EX);
    }
}

// ---------------------------------------- src/Upload/ServicoDeUpload.php
declare(strict_types=1);

namespace App\Upload;

use App\Storage\StorageInterface;
use finfo;

final class ServicoDeUpload
{
    /**
     * O backend chega pronto pelo construtor. O serviço não sabe — e não pode
     * saber — se está falando com disco, S3 ou um dublê de teste.
     */
    public function __construct(
        private readonly StorageInterface $storage,
        private readonly int $tamanhoMaximo = 2 * 1024 * 1024,
        /** @var array<string, string> MIME real → extensão */
        private readonly array $tiposAceitos = [
            'image/jpeg' => 'jpg',
            'image/png'  => 'png',
            'image/webp' => 'webp',
        ],
    ) {}

    public function receber(array $arquivo, string $prefixo = 'avatares'): string
    {
        if ($arquivo['error'] !== UPLOAD_ERR_OK) {
            throw FalhaNoUpload::doCodigoPhp($arquivo['error']);
        }

        if ($arquivo['size'] > $this->tamanhoMaximo) {
            throw new FalhaNoUpload('Arquivo grande demais.');
        }

        $mime = (new finfo(FILEINFO_MIME_TYPE))->file($arquivo['tmp_name']);

        if (!is_string($mime) || !isset($this->tiposAceitos[$mime])) {
            throw new FalhaNoUpload('Formato não aceito.');
        }

        // Prefixo por data espalha os objetos: 200 mil arquivos numa pasta só
        // deixam qualquer `ls` — e o próprio filesystem — de joelhos.
        $chave = sprintf(
            '%s/%s/%s.%s',
            $prefixo,
            date('Y/m'),
            bin2hex(random_bytes(16)),
            $this->tiposAceitos[$mime],
        );

        return $this->storage->guardar($arquivo['tmp_name'], $chave);
    }

    public function url(string $chave): string
    {
        return $this->storage->url($chave);
    }
}

// ---------------------------------------- config/storage.php  (composition root)
declare(strict_types=1);

use App\Storage\LocalStorage;
use App\Storage\S3Storage;
use App\Storage\StorageInterface;

// O ÚNICO ponto do sistema que decide qual backend existe.
return static function (): StorageInterface {
    return match (getenv('STORAGE_DRIVER') ?: 'local') {
        's3' => new S3Storage(
            bucket:        getenv('S3_BUCKET') ?: 'meu-bucket',
            regiao:        getenv('S3_REGIAO') ?: 'us-east-1',
            pastaSimulada: __DIR__ . '/../storage/s3-simulado',
            arquivoDeLog:  __DIR__ . '/../storage/logs/s3.log',
        ),
        default => new LocalStorage(__DIR__ . '/../storage/publico'),
    };
};

// ---------------------------------------- tests/ServicoDeUploadTest.php
declare(strict_types=1);

namespace Tests;

use App\Storage\StorageInterface;
use App\Upload\ServicoDeUpload;
use PHPUnit\Framework\TestCase;

final class ServicoDeUploadTest extends TestCase
{
    public function testGeraChaveComPrefixoDeDataEEstensaoDoMimeReal(): void
    {
        // O dublê é a prova de que a injeção valeu a pena: teste de upload
        // sem disco, sem rede e sem credencial de nuvem.
        $storage = new class implements StorageInterface {
            public array $guardados = [];

            public function guardar(string $origemTemporaria, string $chave): string
            {
                $this->guardados[] = $chave;
                return $chave;
            }

            public function ler(string $chave): string { return ''; }
            public function existe(string $chave): bool { return in_array($chave, $this->guardados, true); }
            public function remover(string $chave): bool { return true; }
            public function url(string $chave): string { return '/memoria/' . $chave; }
        };

        // Um PNG 1x1 de verdade: o serviço lê o MIME dos bytes, então não
        // adianta um arquivo de texto com nome .png.
        $temporario = tempnam(sys_get_temp_dir(), 'up');
        file_put_contents($temporario, base64_decode(
            'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk'
            . 'YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
        ));

        $chave = (new ServicoDeUpload($storage))->receber([
            'name'     => 'foto.png',
            'type'     => 'image/png',
            'tmp_name' => $temporario,
            'error'    => UPLOAD_ERR_OK,
            'size'     => filesize($temporario),
        ]);

        $this->assertMatchesRegularExpression(
            '#^avatares/\d{4}/\d{2}/[0-9a-f]{32}\.png$#',
            $chave,
        );
        $this->assertSame([$chave], $storage->guardados);

        unlink($temporario);
    }
}

Exercício 5

Todo upload grava em dois lugares: o disco, que não tem transação, e o banco, que tem. Escreva um serviço que lide com a falha de cada um dos dois — e decida, justificando, em que ordem gravar. Depois implemente a varredura que recolhe o que sobrou quando nem o tratamento de erro chegou a rodar, tomando o cuidado de não apagar arquivo de upload que ainda esteja em andamento.

Ver resposta

✓ Resposta: Não existe forma de fazer disco e banco acertarem juntos; o que se escolhe é qual inconsistência sobra. Gravando o arquivo primeiro, uma falha no banco deixa um arquivo sem dono: lixo invisível, que uma rotina recolhe depois e que não quebra nada enquanto existe. Na ordem inversa, a falha deixa um registro apontando para arquivo inexistente — a galeria abre com imagem quebrada e o download devolve 404, ou seja, o defeito chega ao usuário. Entre lixo e registro mentiroso, prefira sempre o lixo. O unlink() no catch cobre o caso normal, mas não o processo morto no meio do caminho, e é por isso que a varredura existe. Nela há um detalhe que decide tudo: filtrar por idade. Sem o corte de uma hora, a limpeza apaga exatamente os arquivos que estão sendo enviados neste instante, ainda sem registro no banco.

<?php
declare(strict_types=1);

/**
 * Upload envolve dois sistemas: o disco, que não tem transação, e o banco,
 * que tem. Fazer os dois "darem certo juntos" é impossível — o que dá para
 * escolher é QUAL inconsistência sobra quando algo falha no meio.
 */
final class UploadTransacional
{
    public function __construct(
        private readonly PDO    $pdo,
        private readonly string $diretorio,
    ) {}

    /**
     * Ordem certa: grava o arquivo PRIMEIRO, depois o registro no banco.
     *
     * Se o banco falhar, sobra um arquivo sem dono — lixo, que uma rotina
     * de limpeza recolhe depois, e que não quebra nada enquanto existe.
     *
     * Na ordem inversa, a falha deixa um registro apontando para um arquivo
     * que não existe: a galeria abre com imagem quebrada, o download devolve
     * 404 e o defeito aparece para o usuário. Entre lixo invisível e
     * registro mentiroso, o lixo é sempre o lado melhor de errar.
     */
    public function salvar(string $conteudo, string $nomeOriginal, int $usuarioId): int
    {
        $chave   = bin2hex(random_bytes(16));
        $destino = $this->diretorio . '/' . $chave;

        if (file_put_contents($destino, $conteudo) === false) {
            throw new RuntimeException('Falha ao gravar o arquivo.');
        }

        try {
            $this->pdo->beginTransaction();

            $stmt = $this->pdo->prepare(
                'INSERT INTO arquivos (chave, nome_original, usuario_id, tamanho)
                 VALUES (?, ?, ?, ?)'
            );
            $stmt->execute([$chave, $nomeOriginal, $usuarioId, strlen($conteudo)]);
            $id = (int) $this->pdo->lastInsertId();

            $this->pdo->commit();
            return $id;
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            // Desfaz o que não tem rollback automático.
            @unlink($destino);
            throw $e;
        }
    }

    /**
     * A rede de segurança: nem sempre o unlink() acima chega a rodar — o
     * processo pode morrer entre a gravação e o commit. Esta varredura
     * recolhe o que ficou, e só mexe no que é velho o bastante para não
     * estar no meio de um upload em andamento.
     */
    public function limparOrfaos(int $idadeMinimaSegundos = 3600): array
    {
        $registrados = $this->pdo->query('SELECT chave FROM arquivos')
                                 ->fetchAll(PDO::FETCH_COLUMN);
        $registrados = array_flip($registrados);

        $removidos = [];
        foreach (glob($this->diretorio . '/*') ?: [] as $caminho) {
            $chave = basename($caminho);
            if (isset($registrados[$chave])) continue;
            if (time() - filemtime($caminho) < $idadeMinimaSegundos) continue;

            @unlink($caminho);
            $removidos[] = $chave;
        }
        return $removidos;
    }
}

// ── Demonstração ──────────────────────────────────────────────────────
$dir = sys_get_temp_dir() . '/up_' . bin2hex(random_bytes(4));
mkdir($dir, 0755, true);

$pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec('CREATE TABLE arquivos (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    chave TEXT NOT NULL UNIQUE,
    nome_original TEXT NOT NULL,
    usuario_id INTEGER NOT NULL,
    tamanho INTEGER NOT NULL
)');

$servico = new UploadTransacional($pdo, $dir);

$id = $servico->salvar('conteudo-ok', 'foto.jpg', 42);
echo "  upload bem-sucedido -> id {$id}, arquivos em disco: " . count(glob("$dir/*")) . "\n";

// Falha no banco depois do arquivo já estar em disco: o catch precisa
// desfazer à mão o que não tem rollback.
try {
    $servico->salvar('conteudo', 'igual.jpg', 42);
    $pdo->exec('DROP TABLE arquivos');          // quebra o INSERT seguinte
    $servico->salvar('conteudo', 'outra.jpg', 42);
} catch (Throwable $e) {
    echo "  o INSERT falhou -> o arquivo recém-gravado foi apagado no catch\n";
    echo "  arquivos em disco agora: " . count(glob("$dir/*")) . "\n";
}
$pdo->exec('CREATE TABLE arquivos (id INTEGER PRIMARY KEY AUTOINCREMENT, chave TEXT NOT NULL UNIQUE, nome_original TEXT NOT NULL, usuario_id INTEGER NOT NULL, tamanho INTEGER NOT NULL)');

// Deixa um órfão antigo e roda a limpeza
file_put_contents("$dir/orfao_antigo", 'lixo');
touch("$dir/orfao_antigo", time() - 7200);
file_put_contents("$dir/orfao_recente", 'em andamento');

$removidos = $servico->limparOrfaos();
echo "  órfãos removidos: " . count($removidos) . " (o recente foi preservado)\n";
echo "  restam em disco: " . count(glob("$dir/*")) . "\n";

array_map('unlink', glob("$dir/*")); rmdir($dir);
Comentários

Mais em PHP

PostgreSQL com PHP
PostgreSQL com PHP

Conectar é só trocar o DSN, mas o que vem depois prende: RETURNING, UPSERT com…

PHP 8.x Moderno
PHP 8.x Moderno

Enums, readonly, atributos e fibers mudaram o que se considera código PHP…

Segurança em Formulários: XSS, CSRF e SQL Injection
Segurança em Formulários: XSS, CSRF e SQL Injection

Como cada um dos três ataques funciona por dentro e por que as defesas acabam…