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.',
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);
}
}
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();
}
}
}
Resumo
| Ponto | Detalhe |
|---|---|
$_FILES['type'] |
Não confie — use finfo para o MIME real |
is_uploaded_file() |
Sempre verifique antes de mover |
| Nome do arquivo | Nunca use o nome original — gere um aleatório |
| Diretório de upload | Fora do document root ou com .htaccess bloqueando execução |
move_uploaded_file() |
Prefira a rename() para uploads |
Exercício da semana
-
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.
-
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.
-
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.
-
Desafio: implemente armazenamento em dois backends intercambiáveis via interface
StorageInterface:LocalStorage(disco) eS3Storage(simulado com arquivo de log). OServicoDeUploaddeve receber o backend por injeção de dependência.
Referências
- PHP Manual — Upload de arquivos: https://www.php.net/manual/pt_BR/features.file-upload.php
- PHP Manual — finfo: https://www.php.net/manual/pt_BR/class.finfo.php
- OWASP — File Upload Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html