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

ORM com Eloquent
ORM com Eloquent

Relacionamento em uma linha, cast automático de tipos, escopo que se encadeia…

Segurança Avançada em PHP
Segurança Avançada em PHP

Criptografia simétrica e assimétrica, dados pessoais sob a LGPD, headers HTTP…

O que é PHP e por que ele ainda importa
O que é PHP e por que ele ainda importa

PHP roda no servidor, e é essa única característica que explica quase tudo…