PHP Upload de Arquivos

Foto de perfil, currículo em PDF, comprovante de pagamento: muitos sites recebem arquivos. Em PHP, isso usa o mesmo formulário que você já conhece, com dois detalhes a mais, e uma superglobal nova, $_FILES. Receber é fácil; o cuidado está em conferir o que chegou, porque um arquivo enviado por um desconhecido é a coisa mais perigosa que um site pode guardar.

Você vai aprender
  • O formulário de upload: method="post", enctype="multipart/form-data" e <input type="file">.
  • A estrutura de $_FILES, vista com var_dump: name, type, tmp_name, error, size.
  • Como guardar o arquivo com move_uploaded_file.
  • Por que não confiar na extensão nem no type, e como descobrir o tipo de verdade com finfo.
  • Os limites de tamanho: upload_max_filesize, post_max_size e MAX_FILE_SIZE.
  • Os códigos de erro UPLOAD_ERR_*.
  • Como dar ao arquivo um nome seguro.

A ideia: a encomenda que chega na portaria

Quando chega um pacote no prédio da Ana, o porteiro não leva direto para o apartamento. Ele guarda numa sala de espera e anota numa ficha: nome escrito na etiqueta, tamanho, se chegou inteiro. A Ana então decide: abre a caixa, confere se é mesmo o que encomendou e leva para casa. Se não buscar, o pacote é descartado.

O upload no PHP é igualzinho:

  • O PHP recebe o arquivo e o guarda numa pasta temporária (a sala de espera), com um nome aleatório.
  • Ele preenche uma ficha em $_FILES: nome original, tamanho, tipo informado, se houve erro.
  • O seu programa confere e, se estiver tudo certo, move o arquivo para uma pasta definitiva. Se não mover, o PHP apaga o temporário assim que a página termina.

E, como na portaria, a etiqueta do pacote pode mentir: o nome e o tipo informados foram escritos por quem enviou.


O formulário de upload

<form action="upload.php" method="post" enctype="multipart/form-data">
  <p>Foto: <input type="file" name="foto" accept="image/*"></p>
  <p>Legenda: <input name="legenda"></p>
  <button>Enviar</button>
</form>
method="post"
Obrigatório. Arquivos não cabem no endereço (GET).
enctype="multipart/form-data"
Encoding type, "tipo de codificação". O formato normal (nome=valor&…, de Formulários HTML) só leva texto. O multipart/form-data divide o pedido em "partes" separadas, e cada parte pode ser um campo de texto ou um arquivo inteiro. Sem isso, o arquivo não vai.
<input type="file" name="foto">
O botão "Escolher arquivo". O name é a chave em $_FILES.
accept="image/*"
Sugere ao navegador mostrar só imagens na janela de escolha. É conforto, não proteção: a pessoa pode trocar para "todos os arquivos".
name="legenda"
Campos de texto continuam funcionando normalmente, e chegam em $_POST.

$_FILES por dentro

Vamos ver a "ficha" que o PHP preenche. Este programa só mostra as duas superglobais:

<?php
var_dump($_FILES);
var_dump($_POST);

No laboratório, o envio é feito com o curl -F (form), que monta um pedido multipart/form-data como o navegador. -F "foto=@gato.png" quer dizer "o campo foto é o arquivo gato.png" (o @ indica arquivo); -F "legenda=Meu gato" é um campo de texto:

$ curl -s localhost:8000/dump.php -F "foto=@gato.png" -F "legenda=Meu gato"
array(1) {
  ["foto"]=>
  array(6) {
    ["name"]=>
    string(8) "gato.png"
    ["full_path"]=>
    string(8) "gato.png"
    ["type"]=>
    string(9) "image/png"
    ["tmp_name"]=>
    string(27) "/tmp/phpv324i422nmvt0CCFjoC"
    ["error"]=>
    int(0)
    ["size"]=>
    int(124)
  }
}
array(1) {
  ["legenda"]=>
  string(8) "Meu gato"
}
ChaveExemploO que éConfiável?
namegato.pngNome do arquivo no computador de quem enviouNão: escrito por quem enviou
full_pathgato.pngO caminho completo, quando se envia uma pasta inteira (PHP 8.1+)Não
typeimage/pngO tipo que o navegador disse que é (o "MIME type")Não: é só a etiqueta do pacote
tmp_name/tmp/phpv324…Onde o PHP guardou o arquivo de verdade (a sala de espera)Sim: quem criou foi o PHP
error0Código do resultado: 0 = deu certo (veja a tabela de erros)Sim
size124Tamanho em bytesSim: o PHP mediu

O tmp_name é diferente a cada envio, e o arquivo nele some quando o PHP termina de responder. Por isso o próximo passo é obrigatório: mover.

Vários arquivos: name="fotos[]" e o formato "virado"

Com <input type="file" name="fotos[]" multiple>, a pessoa escolhe vários arquivos de uma vez. Os colchetes funcionam como em $_GET e $_POST, mas o array sai num formato curioso: em vez de uma ficha por arquivo, vem uma lista por chave:

$ curl -s localhost:8000/dump.php -F "fotos[]=@gato.png" -F "fotos[]=@bolo.jpg"
array(1) {
  ["fotos"]=>
  array(6) {
    ["name"]=>
    array(2) {
      [0]=>
      string(8) "gato.png"
      [1]=>
      string(8) "bolo.jpg"
    }
    …
    ["error"]=>
    array(2) {
      [0]=>
      int(0)
      [1]=>
      int(0)
    }
    ["size"]=>
    array(2) {
      [0]=>
      int(124)
      [1]=>
      int(716)
    }
  }
}

O primeiro arquivo é $_FILES['fotos']['name'][0], $_FILES['fotos']['tmp_name'][0], e assim por diante. Para percorrer:

foreach ($_FILES['fotos']['name'] as $i => $nome) {
    $erro = $_FILES['fotos']['error'][$i];
    $tmp  = $_FILES['fotos']['tmp_name'][$i];
    // confira e mova cada um, como no exemplo completo abaixo
}

Um formulário de upload de verdade. Rode, escolha um arquivo qualquer do seu computador na aba Resultado e envie: o var_dump mostra a ficha que o PHP preencheu. Tente também enviar sem escolher arquivo (repare no error), e depois apague o enctype e envie de novo. No editor do guia, o arquivo vai para o PHP que roda dentro do seu próprio navegador: nada é enviado para a internet. Se o seu navegador não deixar escolher arquivos aqui dentro, use a caixa logo abaixo, em que um campo de texto faz o papel do arquivo.

<form method="post" enctype="multipart/form-data">
  <p>Arquivo: <input type="file" name="foto"></p>
  <p>Legenda: <input name="legenda"></p>
  <button>Enviar</button>
</form>
<h3>$_FILES</h3>
<pre><?php var_dump($_FILES); ?></pre>
<h3>$_POST</h3>
<pre><?php var_dump($_POST); ?></pre>

Guardar: move_uploaded_file

Para tirar o arquivo da sala de espera, use move_uploaded_file ("mover arquivo enviado"):

<?php
$arq = $_FILES['foto'];
$destino = 'uploads/' . $arq['name'];
if (move_uploaded_file($arq['tmp_name'], $destino)) {
    echo "Salvo em $destino\n";
} else {
    echo "Não foi possível salvar.\n";
}
move_uploaded_file(
Move o arquivo e devolve true (deu certo) ou false.
$arq['tmp_name'],
De onde: o arquivo temporário. A função só aceita arquivos que chegaram por upload neste pedido; um caminho qualquer, como /etc/passwd, é recusado. Por isso use ela, e não rename ou copy.
$destino)
Para onde: o caminho final, com o nome do arquivo. A pasta precisa existir e o PHP precisa ter permissão de escrita nela.

A primeira vez que a Ana testou, a pasta uploads ainda não existia:

$ curl -s localhost:8000/mover.php -F "foto=@gato.png"
<br />
<b>Warning</b>:  move_uploaded_file(uploads/gato.png): Failed to open stream: No such file or directory in <b>/home/ana/site/mover.php</b> on line <b>4</b><br />
<br />
<b>Warning</b>:  move_uploaded_file(): Unable to move &quot;/tmp/php5942e2shniftfKdlkkp&quot; to &quot;uploads/gato.png&quot; in <b>/home/ana/site/mover.php</b> on line <b>4</b><br />
Não foi possível salvar.
$ mkdir uploads
$ curl -s localhost:8000/mover.php -F "foto=@gato.png"
Salvo em uploads/gato.png
$ ls -l uploads
total 4
-rw-rw-r-- 1 ana ana 124 out 16 20:58 gato.png

Funcionou, mas este mover.php tem três falhas graves: não confere se houve erro, aceita qualquer tipo de arquivo e usa o nome escolhido por quem enviou. As próximas seções fecham cada uma.


Os códigos de erro (UPLOAD_ERR_*)

A chave error diz como foi o envio. Cada número tem uma constante com nome, que deixa o código mais legível:

ValorConstanteSignificaO que dizer ao visitante
0UPLOAD_ERR_OKDeu tudo certo—
1UPLOAD_ERR_INI_SIZEMaior que o upload_max_filesize do php.ini"Arquivo grande demais"
2UPLOAD_ERR_FORM_SIZEMaior que o campo MAX_FILE_SIZE do formulário"Arquivo grande demais"
3UPLOAD_ERR_PARTIALChegou só um pedaço (conexão caiu)"Tente de novo"
4UPLOAD_ERR_NO_FILENenhum arquivo foi escolhido"Escolha um arquivo"
6UPLOAD_ERR_NO_TMP_DIRFalta a pasta temporária no servidorProblema do servidor: avise o administrador
7UPLOAD_ERR_CANT_WRITENão conseguiu gravar no disco (cheio, sem permissão)Problema do servidor
8UPLOAD_ERR_EXTENSIONUma extensão do PHP interrompeu o envioProblema do servidor

(Não existe o 5.) Enviar o formulário sem escolher arquivo dá uma ficha "vazia", com error 4:

$ curl -s localhost:8000/dump.php -F "foto=@/dev/null;filename="
array(1) {
  ["foto"]=>
  array(6) {
    ["name"]=>
    string(0) ""
    ["full_path"]=>
    string(0) ""
    ["type"]=>
    string(0) ""
    ["tmp_name"]=>
    string(0) ""
    ["error"]=>
    int(4)
    ["size"]=>
    int(0)
  }
}
…

(O filename= vazio é como o navegador avisa "nenhum arquivo escolhido".) Repare: $_FILES['foto'] existe mesmo sem arquivo. Por isso não basta isset($_FILES['foto']): confira o error.


Limites de tamanho

Três configurações controlam o tamanho, e é bom conhecer as três porque cada uma falha de um jeito:

LimiteOndePadrãoSe passar
upload_max_filesizephp.ini2MAquele arquivo vem com error 1; os outros campos chegam normalmente
post_max_sizephp.ini8MO pedido inteiro é descartado: $_POST e $_FILES chegam vazios
MAX_FILE_SIZECampo escondido no formulário—error 2. Só ajuda; quem envia pode mudar o valor

Os padrões do PHP da Ana (sem php.ini):

$ php -i | grep -E "upload_max_filesize|post_max_size|max_file_uploads"
max_file_uploads => 20 => 20
post_max_size => 8M => 8M
upload_max_filesize => 2M => 2M

Um arquivo de 3 MB (passa dos 2M) e um de 9 MB (passa até dos 8M do pedido inteiro), enviados para este programa:

<?php
var_dump($_FILES['foto']['error'] ?? 'nada em $_FILES');
var_dump($_POST);
$ curl -s localhost:8000/erro.php -F "foto=@grande.bin" -F "legenda=x"
int(1)                « UPLOAD_ERR_INI_SIZE
array(1) {
  ["legenda"]=>
  string(1) "x"        « o resto do formulário chegou
}
$ curl -s localhost:8000/erro.php -F "foto=@enorme.bin" -F "legenda=x"
<br />
<b>Warning</b>:  PHP Request Startup: POST Content-Length of 9000317 bytes exceeds the limit of 8388608 bytes in <b>Unknown</b> on line <b>0</b><br />
string(15) "nada em $_FILES"
array(0) {           « nem a legenda chegou
}

O segundo caso é traiçoeiro: o PHP descarta tudo antes do seu código rodar ("Request Startup" = início do pedido; "Unknown on line 0" porque nenhum arquivo seu estava rodando ainda). Para o seu programa, parece que o formulário veio vazio. Se REQUEST_METHOD é POST mas $_POST e $_FILES estão vazios, desconfie do post_max_size.

O campo MAX_FILE_SIZE vai antes do <input type="file">:

<input type="hidden" name="MAX_FILE_SIZE" value="1000000">
<input type="file" name="foto">
$ curl -s localhost:8000/erro.php -F "MAX_FILE_SIZE=500" -F "foto=@bolo.jpg"
int(2)                « bolo.jpg tem 716 bytes: UPLOAD_ERR_FORM_SIZE
array(1) {
  ["MAX_FILE_SIZE"]=>
  string(3) "500"
}

Para aceitar arquivos maiores, aumente os dois no php.ini (com post_max_size um pouco maior que upload_max_filesize, porque o pedido também leva os outros campos), ou, no servidor de testes, na linha de comando:

$ php -d upload_max_filesize=10M -d post_max_size=12M -S localhost:8000

E, no seu código, tenha o seu limite ($arq['size'] > 1_000_000), que não depende da configuração do servidor.


Validar o tipo: não confie na extensão

Uma foto chamada gato.jpg é uma foto? Não necessariamente. O nome (e a extensão .jpg) e o type (image/jpeg) foram escritos por quem enviou. A Ana copiou um arquivo de texto com o nome falsa.jpg e mandou com o curl dizendo que era JPEG:

<?php
$arq = $_FILES['foto'];
echo "O navegador disse: ", $arq['type'], "\n";
$finfo = new finfo(FILEINFO_MIME_TYPE);
echo "O finfo viu:       ", $finfo->file($arq['tmp_name']), "\n";
echo "mime_content_type: ", mime_content_type($arq['tmp_name']), "\n";
$ curl -s localhost:8000/tipo.php -F "foto=@falsa.jpg;type=image/jpeg"
O navegador disse: image/jpeg      « a etiqueta mentiu
O finfo viu:       text/plain      « o conteúdo é texto
mime_content_type: text/plain
$ curl -s localhost:8000/tipo.php -F "foto=@bolo.jpg"
O navegador disse: image/jpeg
O finfo viu:       image/jpeg
mime_content_type: image/jpeg
new finfo(FILEINFO_MIME_TYPE)
Cria um "inspetor de arquivos" (file info) da extensão fileinfo. Ele abre o arquivo e olha os primeiros bytes, a "assinatura" que todo formato tem (um PNG sempre começa com os bytes ‰PNG, um JPEG com FF D8 FF). FILEINFO_MIME_TYPE pede a resposta no formato tipo/subtipo.
$finfo->file($caminho)
Devolve o tipo descoberto pelo conteúdo. (A seta -> chama um método de um objeto, assunto de Classes e Objetos; aqui, basta copiar a forma.)
mime_content_type($caminho)
Faz o mesmo numa função só. Use a que achar mais simples.

Por que isso importa? O pior caso é alguém enviar um programa PHP com nome de foto. Se ele for salvo numa pasta acessível pelo navegador com a extensão .php, o atacante abre o endereço e o servidor executa o programa dele. O finfo não se engana com o nome:

$ php tipos.php
falsa.jpg: text/plain
foto.png: image/png
script.jpg: text/x-php            « um programa PHP disfarçado de .jpg

A defesa tem duas partes, e as duas são necessárias:

  1. Aceitar só os tipos que você espera, descobertos pelo finfo, numa lista (['image/jpeg' => 'jpg', 'image/png' => 'png']).
  2. Escolher a extensão do arquivo salvo a partir dessa lista, nunca a partir do nome original. Assim, mesmo um arquivo malicioso nunca é salvo como .php.

Nomes seguros

Usar $arq['name'] como nome do arquivo salvo traz vários problemas:

  • Sobrescrever: duas pessoas enviam foto.jpg, e a segunda apaga a primeira.
  • Extensões perigosas: foto.php, .htaccess.
  • Caracteres problemáticos: espaços, acentos, parênteses (minha foto (1).PNG), nomes enormes.
  • Caminhos: um nome como ../../index.php tentaria sair da pasta. O PHP já corta o caminho do name (fica só index.php), mas o full_path mantém tudo:
$ curl -s localhost:8000/dump.php -F "foto=@receita.txt;filename=../../index.php"
array(1) {
  ["foto"]=>
  array(6) {
    ["name"]=>
    string(9) "index.php"
    ["full_path"]=>
    string(15) "../../index.php"
…

A solução é inventar o nome: letras aleatórias + a extensão da sua lista. random_bytes(16) gera 16 bytes aleatórios, e bin2hex os escreve como 32 letras e números (0-9, a-f):

$novoNome = bin2hex(random_bytes(16)) . '.' . $tipos[$tipo];
// por exemplo: 1a99be15d05c6e4708f3bc45fd5c9c9b.png

Se precisar mostrar o nome original depois ("gato.png"), guarde-o num banco de dados junto do nome novo, e mostre-o com htmlspecialchars (Escapar a Saída).


Tudo junto: o upload seguro

<?php
$mensagem = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $arq = $_FILES['foto'] ?? null;
    $tipos = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];

    if ($arq === null || !is_int($arq['error'])) {
        $mensagem = 'Nenhum arquivo recebido.';
    } elseif ($arq['error'] === UPLOAD_ERR_NO_FILE) {
        $mensagem = 'Escolha uma foto.';
    } elseif ($arq['error'] === UPLOAD_ERR_INI_SIZE || $arq['error'] === UPLOAD_ERR_FORM_SIZE) {
        $mensagem = 'A foto é grande demais.';
    } elseif ($arq['error'] !== UPLOAD_ERR_OK) {
        $mensagem = 'O envio falhou (código ' . $arq['error'] . ').';
    } elseif ($arq['size'] > 1_000_000) {
        $mensagem = 'A foto pode ter no máximo 1 MB.';
    } else {
        $finfo = new finfo(FILEINFO_MIME_TYPE);
        $tipo = $finfo->file($arq['tmp_name']);
        if (!isset($tipos[$tipo])) {
            $mensagem = 'Envie uma imagem JPG, PNG ou WebP.';
        } else {
            $novoNome = bin2hex(random_bytes(16)) . '.' . $tipos[$tipo];
            if (move_uploaded_file($arq['tmp_name'], __DIR__ . '/uploads/' . $novoNome)) {
                $mensagem = 'Foto salva como ' . $novoNome;
            } else {
                $mensagem = 'Não foi possível salvar a foto.';
            }
        }
    }
}
?>
<p><?= htmlspecialchars($mensagem) ?></p>
<form method="post" enctype="multipart/form-data">
  <input type="hidden" name="MAX_FILE_SIZE" value="1000000">
  <input type="file" name="foto" accept="image/*">
  <button>Enviar</button>
</form>
TrechoPor quê
$_FILES['foto'] ?? nullO pedido pode nem ter o campo (ou ter passado do post_max_size).
!is_int($arq['error'])Se alguém mandou foto[], o error vira array. Recuse.
a sequência de elseif com UPLOAD_ERR_*Uma mensagem clara para cada problema; só segue com UPLOAD_ERR_OK.
$arq['size'] > 1_000_000O seu limite (1 MB), independente do servidor. O _ é só para ler melhor o número.
$finfo->file(…) + isset($tipos[$tipo])Tipo pelo conteúdo, conferido numa lista de permitidos.
bin2hex(random_bytes(16)) . '.' . $tipos[$tipo]Nome inventado, extensão vinda da lista.
__DIR__ . '/uploads/'__DIR__ é a pasta deste arquivo PHP: o destino não depende de onde o servidor foi aberto.

Testando cada situação (só a primeira linha da resposta, com a mensagem):

$ curl -s localhost:8000/upload.php -F "foto=@gato.png" | head -1
<p>Foto salva como 1a99be15d05c6e4708f3bc45fd5c9c9b.png</p>
$ curl -s localhost:8000/upload.php -F "foto=@falsa.jpg;type=image/jpeg" | head -1
<p>Envie uma imagem JPG, PNG ou WebP.</p>
$ curl -s localhost:8000/upload.php -F "foto=@grande.bin" | head -1
<p>A foto é grande demais.</p>
$ curl -s localhost:8000/upload.php -F "foto=@/dev/null;filename=" | head -1
<p>Escolha uma foto.</p>
$ ls -l uploads
total 8
-rw-rw-r-- 1 ana ana 124 out 16 20:59 1a99be15d05c6e4708f3bc45fd5c9c9b.png
-rw-rw-r-- 1 ana ana 124 out 16 20:58 gato.png
Mais duas proteções num site de verdade: guarde os uploads, se possível, numa pasta fora da pasta pública do site (e entregue-os por um PHP que lê o arquivo), e configure o servidor web para nunca executar PHP dentro da pasta de uploads. Assim, mesmo que algo passe, não roda.

Sem arquivo de verdade: aqui um campo de texto faz o papel do arquivo. O PHP grava o que você digitar num arquivo temporário e pergunta ao finfo o que ele é, ignorando o nome. Experimente o nome foto.jpg com um texto qualquer, depois com <?php echo 1;, depois com {"nome": "Ana"}.

<?php
$nome = $_POST['nome'] ?? 'foto.jpg';
$conteudo = $_POST['conteudo'] ?? 'Lista: farinha, ovos, leite';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $tmp = tempnam(sys_get_temp_dir(), 'up');
    file_put_contents($tmp, $conteudo);
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    echo "<p>O nome diz: <b>" . htmlspecialchars($nome) . "</b><br>";
    echo "O finfo viu: <b>" . htmlspecialchars($finfo->file($tmp)) . "</b></p>";
    unlink($tmp);
}
?>
<form method="post">
  <p>Nome do "arquivo": <input name="nome" value="<?= htmlspecialchars($nome) ?>"></p>
  <p>Conteúdo:<br><textarea name="conteudo" rows="4" cols="40"><?= htmlspecialchars($conteudo) ?></textarea></p>
  <button>"Enviar"</button>
</form>

Quando dá errado

$_FILES vazio, e o nome do arquivo apareceu em $_POST
$ curl -s localhost:8000/dump.php -d "foto=gato.png"
array(0) {
}
array(1) {
  ["foto"]=>
  string(8) "gato.png"
}

O que aconteceu: o formulário não tem enctype="multipart/form-data". Sem ele, o navegador manda só o nome do arquivo, como texto comum (é o que o curl -d simula aqui), e o conteúdo fica para trás.

Como resolver: <form method="post" enctype="multipart/form-data">. Com method="get" também não funciona.

PHP Request Startup: POST Content-Length of … bytes exceeds the limit
<b>Warning</b>:  PHP Request Startup: POST Content-Length of 9000317 bytes exceeds the limit of 8388608 bytes in <b>Unknown</b> on line <b>0</b><br />

O que aconteceu: o pedido inteiro passou do post_max_size (8388608 bytes = 8 MB). O PHP jogou fora $_POST e $_FILES.

Como resolver: aumente post_max_size (e upload_max_filesize) no php.ini, ou avise o limite no formulário. No código, trate "POST com $_FILES vazio" como "arquivo grande demais".

move_uploaded_file(…): Failed to open stream: No such file or directory

O que aconteceu: a pasta de destino não existe (ou o caminho está errado). A mensagem seguinte, Unable to move "/tmp/php…" to "uploads/…", é a consequência.

Como resolver: crie a pasta (mkdir uploads) e use caminho absoluto: __DIR__ . '/uploads/'. Se a mensagem for Permission denied, o PHP não tem permissão de escrita na pasta (no Linux, confira o dono e as permissões da pasta com ls -ld uploads).

O upload "deu certo", mas o arquivo sumiu

O que aconteceu: o código leu $_FILES mas não chamou move_uploaded_file (ou chamou com o destino errado e não conferiu o retorno). O arquivo temporário é apagado quando a página termina.

Como resolver: mova sempre, e confira: if (!move_uploaded_file(…)) { … }.

error 1 com um arquivo de 3 MB

O que aconteceu: o padrão de upload_max_filesize é 2M. Fotos de celular passam disso com facilidade.

Como resolver: aumente no php.ini (e o post_max_size junto), ou reduza a foto antes de enviar. Confira o valor em uso com php -i | grep upload_max_filesize ou ini_get('upload_max_filesize').


Referência

ItemO que é
enctype="multipart/form-data"Obrigatório no <form> para enviar arquivos (com method="post")
<input type="file" name="x">Campo de arquivo; multiple + name="x[]" para vários
$_FILES['x']['name']Nome original (não confiável)
$_FILES['x']['type']Tipo informado pelo navegador (não confiável)
$_FILES['x']['tmp_name']Caminho do arquivo temporário
$_FILES['x']['error']UPLOAD_ERR_OK (0) ou um código de erro
$_FILES['x']['size']Tamanho em bytes
move_uploaded_file($tmp, $destino)Move o arquivo enviado; true/false
(new finfo(FILEINFO_MIME_TYPE))->file($tmp)Tipo real, pelo conteúdo
mime_content_type($tmp)O mesmo, numa função
bin2hex(random_bytes(16))32 caracteres aleatórios para o nome
upload_max_filesize / post_max_sizeLimites do php.ini (padrão 2M / 8M)
MAX_FILE_SIZECampo escondido, antes do type="file"; gera o erro 2

Exercícios

1. O atributo esquecido

Complete o formulário de upload:

<form method="post" enctype="">

2. Deu certo?

Complete com a constante que significa "upload sem erro":

if ($arq['error'] === ) { … }

3. Mover

Complete com a função que guarda o arquivo enviado:

($arq['tmp_name'], $destino);

4. Qual informação de $_FILES é confiável para saber se o arquivo é mesmo uma imagem?

5. A pessoa enviou um arquivo maior que post_max_size. O que o seu código encontra?

6. Qual é o nome mais seguro para salvar uma foto enviada?

Missão: o finfo já descobriu que o arquivo é image/png. Monte em $novo um nome seguro: 32 caracteres aleatórios (bin2hex(random_bytes(16))), um ponto e a extensão que a lista $tipos dá para esse tipo. Ignore o nome original. A saída deve ser algo como 3f9a…c1.png.

<?php
$tipos = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
$nomeOriginal = '../../minha foto (1).PHP';
$tipo = 'image/png';   // o que o finfo detectou

$novo = $nomeOriginal;   // troque por um nome seguro

echo $novo, "\n";
<?php
$tipos = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
$nomeOriginal = '../../minha foto (1).PHP';
$tipo = 'image/png';   // o que o finfo detectou

$novo = bin2hex(random_bytes(16)) . '.' . $tipos[$tipo];

echo $novo, "\n";

Missão: abaixo está a ficha que $_FILES['foto'] recebe quando a pessoa clica em Enviar sem escolher arquivo (copiada do var_dump real). Rode: o match cai no default. Acrescente um braço para UPLOAD_ERR_NO_FILE com a mensagem Você não escolheu nenhum arquivo. (o match está em match).

<?php
$arq = ['name' => '', 'full_path' => '', 'type' => '', 'tmp_name' => '', 'error' => 4, 'size' => 0];

$mensagem = match ($arq['error']) {
    UPLOAD_ERR_OK => 'Arquivo recebido!',
    UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'Arquivo grande demais.',
    default => 'Erro no envio (código ' . $arq['error'] . ').',
};
echo $mensagem, "\n";
<?php
$arq = ['name' => '', 'full_path' => '', 'type' => '', 'tmp_name' => '', 'error' => 4, 'size' => 0];

$mensagem = match ($arq['error']) {
    UPLOAD_ERR_OK => 'Arquivo recebido!',
    UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'Arquivo grande demais.',
    UPLOAD_ERR_NO_FILE => 'Você não escolheu nenhum arquivo.',
    default => 'Erro no envio (código ' . $arq['error'] . ').',
};
echo $mensagem, "\n";

Resumo

  • Upload precisa de method="post", enctype="multipart/form-data" e <input type="file" name="…">.
  • $_FILES['campo'] traz name, full_path, type, tmp_name, error e size. Nome e tipo foram escritos por quem enviou.
  • Confira error (UPLOAD_ERR_OK = 0) antes de tudo; 4 = nenhum arquivo, 1 e 2 = grande demais.
  • Limites: upload_max_filesize (2M) por arquivo; post_max_size (8M) para o pedido inteiro, que, se estourar, esvazia $_POST e $_FILES.
  • Descubra o tipo pelo conteúdo, com finfo ou mime_content_type, e aceite só uma lista de tipos.
  • Salve com move_uploaded_file, com um nome inventado (bin2hex(random_bytes(16))) e a extensão da sua lista.