Dia 14 — Formulários e transações: PHP e MySQL
O banco já responde ao PHP; falta a outra ponta, que é o dado digitado por alguém. Este dia pega o formulário HTML, o `$_POST` e o `$_GET`, valida o que chegou e grava. É também onde entram a transação, que agrupa várias gravações em tudo ou nada, e o envio de arquivo, que precisa de `multipart/form-data`.
Formulários GET e POST
Um formulário tem dois métodos: get manda os dados na URL, post manda no corpo da requisição. A escolha define quem pode ver o que foi digitado e o que acontece quando a página é recarregada.
A estrutura do formulário
<form action="/cadastrar.php" method="post"> <label for="nome">Nome</label> <input type="text" id="nome" name="nome" maxlength="120" required> <label for="email">E-mail</label> <input type="email" id="email" name="email" required> <button type="submit">Cadastrar</button> </form>
O name é o que importa: sem ele, o campo não chega no PHP. O id liga o campo ao label e é o que permite clicar no texto da etiqueta para focar o campo.
GET contra POST
GET | POST | |
|---|---|---|
| Os dados ficam | na URL, depois do ? | no corpo da requisição |
| Aparece no histórico | sim | não |
| Recarregar a página | repete a busca, sem duplicar nada | pode reenviar o envio |
| Tamanho | limitado pelo navegador, na casa dos 2 mil caracteres | limitado por post_max_size |
| Serve para | buscar, filtrar, paginar | gravar, apagar, pagar, enviar |
Com get, o resultado fica bookmarkável: /buscar.php?termo=Ana+Souza pode ser enviado para o colega e abre a mesma página. Com post, a URL fica sempre a mesma.
input type="hidden"
O campo hidden é enviado junto com o resto e não aparece na tela. Serve para o id do registro em edição ou para a página de destino.
Ele não serve para guardar decisão de permissão. O valor vem do navegador e volta alterado, exatamente como qualquer outro campo: quem não tem permissão para excluir uma conta precisa ser impedido no servidor, não no formulário.
Os tipos mais usados em input: text, email, number, date, password, checkbox, radio, hidden, file e submit. Os atributos required, min, max e maxlength são conferidos pelo navegador antes do envio, mas a validação de verdade continua no PHP.
Exemplo
<?php declare(strict_types=1); // Formulario GET e POST. // // O gerador roda este arquivo em php:8.3-cli, sem servidor web: nao existe // $_GET nem $_POST necessarios, porque a leitura do formulario depende de uma // requisicao. Entao o HTML do form e o codigo de leitura aparecem como texto. // O que roda de verdade aqui embaixo e a comparacao entre a URL de GET e a de // POST, montada com http_build_query, que e a mesma serializacao que o PHP // usa para receber o formulario. echo "=== 1. O mesmo formulario, escrito de dois jeitos ===\n"; $htmlGet = <<<'HTML' <!-- GET: os dados vao na URL. A pagina pode ser favoritada e recarregada. --> <form action="/buscar.php" method="get"> <label for="termo">Aluno</label> <input type="search" id="termo" name="termo" placeholder="nome ou e-mail" required> <input type="hidden" name="ordem" value="nome"> <button type="submit">Buscar</button> </form> HTML; $htmlPost = <<<'HTML' <!-- POST: os dados vao no corpo da requisicao. Use quando o dado altera algo. --> <form action="/cadastrar.php" method="post"> <label for="nome">Nome</label> <input type="text" id="nome" name="nome" maxlength="120" required> <label for="email">E-mail</label> <input type="email" id="email" name="email" required> <button type="submit">Cadastrar</button> </form> HTML; echo "{$htmlGet}\n\n{$htmlPost}\n"; // --- O que o PHP recebe --- echo "\n=== 2. O que cada um vira no PHP ===\n"; $parceiros = [ 'method="get"' => '$_GET[\'termo\']', 'method="post"' => '$_POST[\'nome\']', ]; foreach ($parceiros as $html => $php) { printf(" %-16s -> %s\n", $html, $php); } // --- GET: a mesma coisa numa URL --- echo "\n=== 3. GET: os dados ficam visiveis na URL ===\n"; $query = http_build_query(['termo' => 'Ana Souza', 'ordem' => 'nome']); $url = "/buscar.php?{$query}"; echo " {$url}\n"; echo " GET: no PHP, o array \$_GET le esse mesmo conteudo, decodificado.\n"; // --- POST: os dados nao ficam na URL --- echo "\n=== 4. POST: os dados nao aparecem na URL ===\n"; echo " URL fica so /cadastrar.php\n"; echo " nome e email via no corpo da requisicao, invisiveis no endereco.\n"; echo " Por isso POST e o metodo de todo formulario que grava, apaga ou paga.\n"; // --- O campo hidden --- echo "\n=== 5. input type=hidden leva valor sem o usuario ver ===\n"; echo " O navegador envia o campo, mas nao o mostra. Serve para o id do\n"; echo " registro em edicao ou para a pagina de destino, nunca para segurar\n"; echo " decisao de permissao: o valor volta alterado.\n"; // --- Os atributos que o aluno procura --- echo "\n=== 6. Atributos que aparecem toda hora ===\n"; $atributos = [ 'action' => 'arquivo que recebe os dados', 'method' => 'get ou post', 'name' => 'a chave que aparece em $_GET e $_POST (sem name, nao vai)', 'type' => 'text, email, number, date, password, checkbox, hidden, file', 'required' => 'bloqueia o envio vazio no proprio navegador', 'placeholder' => 'texto de exemplo, some quando o usuario digita', 'value' => 'conteudo inicial do campo', 'maxlength' => 'tamanho maximo, validado antes do envio', 'min/max' => 'limites de number, date e range', ]; foreach ($atributos as $attr => $uso) { printf(" %-18s %s\n", $attr, $uso); }
Saída real
=== 1. O mesmo formulario, escrito de dois jeitos ===
<!-- GET: os dados vao na URL. A pagina pode ser favoritada e recarregada. -->
<form action="/buscar.php" method="get">
<label for="termo">Aluno</label>
<input type="search" id="termo" name="termo" placeholder="nome ou e-mail" required>
<input type="hidden" name="ordem" value="nome">
<button type="submit">Buscar</button>
</form>
<!-- POST: os dados vao no corpo da requisicao. Use quando o dado altera algo. -->
<form action="/cadastrar.php" method="post">
<label for="nome">Nome</label>
<input type="text" id="nome" name="nome" maxlength="120" required>
<label for="email">E-mail</label>
<input type="email" id="email" name="email" required>
<button type="submit">Cadastrar</button>
</form>
=== 2. O que cada um vira no PHP ===
method="get" -> $_GET['termo']
method="post" -> $_POST['nome']
=== 3. GET: os dados ficam visiveis na URL ===
/buscar.php?termo=Ana+Souza&ordem=nome
GET: no PHP, o array $_GET le esse mesmo conteudo, decodificado.
=== 4. POST: os dados nao aparecem na URL ===
URL fica so /cadastrar.php
nome e email via no corpo da requisicao, invisiveis no endereco.
Por isso POST e o metodo de todo formulario que grava, apaga ou paga.
=== 5. input type=hidden leva valor sem o usuario ver ===
O navegador envia o campo, mas nao o mostra. Serve para o id do
registro em edicao ou para a pagina de destino, nunca para segurar
decisao de permissao: o valor volta alterado.
=== 6. Atributos que aparecem toda hora ===
action arquivo que recebe os dados
method get ou post
name a chave que aparece em $_GET e $_POST (sem name, nao vai)
type text, email, number, date, password, checkbox, hidden, file
required bloqueia o envio vazio no proprio navegador
placeholder texto de exemplo, some quando o usuario digita
value conteudo inicial do campo
maxlength tamanho maximo, validado antes do envio
min/max limites de number, date e range
Recebendo e validando dados
O que chega do formulário é sempre texto e sempre pode faltar. O $_POST é um array comum, então $_POST['nota'] em campo que não veio gera aviso. Validar é conferir tipo, formato e faixa antes de usar o valor no banco.
Lendo sem aviso
trim(string $texto, string $caracteres = " \t\n\r\0\x0B"): string tira o espaço das duas pontas. O operador ?? devolve o valor à direita quando a chave não existe ou é null, e isset() só responde se a chave existe e não é null.
$email = trim($_POST['email'] ?? ''); $nota = $_POST['nota'] ?? ''; if ($email === '' || $nota === '') { exit('Preencha os dois campos.'); }
empty() devolve true para '', '0', 0, null, false e array vazio. Serve para campo em branco, mas tem a armadilha clássica: empty('0') é true, então o valor "0" — que é um texto válido — é tratado como vazio.
filter_var
filter_var(mixed $valor, int $filtro, array $opcoes = []): mixed devolve o valor convertido quando ele serve, e false quando não serve. É validação de verdade: o tipo do valor é conferido, não só o formato.
| Filtro | O que devolve |
|---|---|
FILTER_VALIDATE_EMAIL | o e-mail, ou false |
FILTER_VALIDATE_INT | o inteiro, ou false |
FILTER_VALIDATE_FLOAT | o real, ou false |
FILTER_VALIDATE_URL | a URL, ou false |
FILTER_VALIDATE_IP | o endereço IPv4 ou IPv6, ou false |
$nota = filter_var($_POST['nota'] ?? '', FILTER_VALIDATE_INT, [ 'options' => ['min_range' => 0, 'max_range' => 10], ]); if ($nota === false) { exit('Nota invalida.'); }
O min_range e o max_range rejeitam o valor fora da faixa, em vez de só conferir se é inteiro. Sem eles, FILTER_VALIDATE_INT aceitaria 200 como nota.
Escapar não é validar
htmlspecialchars(string $texto, int $flags = ENT_QUOTES, string $encoding = 'UTF-8'): string troca <, >, & e as aspas pelas entidades, para que o texto apareça na tela em vez de virar tag.
São duas coisas diferentes e que não se substituem: validar é conferir se o dado é aceitável para o que se quer fazer, e acontece na entrada. Escapar é proteger a saída, e acontece na hora de imprimir.
Escapar na entrada quebra o dado guardado e não protege a saída: o valor já escapado chega ao banco com < no lugar de <, e um programa que leia esse dado em outro lugar, como um e-mail, vê o escapamento como texto.
Exemplo
<?php declare(strict_types=1); // Recebendo e validando dados do formulario. // // A leitura de $_POST depende de requisicao web, entao o trecho de formulario // e o de validacao aparecem como texto. Tudo o que roda de verdade aqui embaixo // sao as funcoes de validacao, aplicadas a um array que imita o que o PHP // colocaria em $_POST. echo "=== 1. Lendo com seguranca o que chegou ===\n"; $codigo = <<<'PHP' <?php declare(strict_types=1); $email = trim($_POST['email'] ?? ''); $nota = $_POST['nota'] ?? ''; if ($email === '' || $nota === '') { exit('Preencha os dois campos.'); } if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) { exit('E-mail invalido.'); } $nota = filter_var($nota, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 10]]); if ($nota === false) { exit('Nota invalida.'); } // Texto que vem do usuario vai para a tela escapado. echo htmlspecialchars($_POST['nome'] ?? '', ENT_QUOTES, 'UTF-8'); PHP; echo $codigo . "\n"; // --- $_GET, $_POST e $_REQUEST --- echo "\n=== 2. As tres superglobais do formulario ===\n"; $globais = [ '$_GET' => 'dados de method="get", pela query string', '$_POST' => 'dados de method="post", pelo corpo da requisicao', '$_REQUEST' => 'a uniao das duas, na ordem GET, POST, COOKIE', ]; foreach ($globais as $nome => $descricao) { printf(" %-11s %s\n", $nome, $descricao); } echo "\n Use \$_POST e \$_GET. \$_REQUEST mistura as duas e esconde a origem do valor.\n"; // --- O array que imita o POST --- $dados = [ 'nome' => ' Ana Souza ', 'email' => '[email protected]', 'idade' => '31', 'site' => 'http://exemplo.com.br', 'bio' => 'Ana; <b>aluna</b> de Eletronica &-formatacao', ]; echo "\n=== 3. trim antes de validar ===\n"; $limpo = trim($dados['nome']); printf(" original: [%s]\n", $dados['nome']); printf(" trim: [%s]\n", $limpo); printf(" o espaco da borda quebraria a validacao de tamanho e o indice.\n"); // --- filter_var --- echo "\n=== 4. filter_var: validacao de verdade ===\n"; echo " filter_var devolve o valor convertido, ou false quando nao serve.\n\n"; $email = filter_var($dados['email'], FILTER_VALIDATE_EMAIL); printf(" FILTER_VALIDATE_EMAIL %-26s %s\n", $dados['email'], var_export($email, true)); $idade = filter_var($dados['idade'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 120]]); printf(" FILTER_VALIDATE_INT %-26s %s\n", $dados['idade'], var_export($idade, true)); $site = filter_var($dados['site'], FILTER_VALIDATE_URL); printf(" FILTER_VALIDATE_URL %-26s %s\n", $dados['site'], var_export($site, true)); $ruim = filter_var('nao e email', FILTER_VALIDATE_EMAIL); printf(" valor invalido %-26s %s\n", 'nao e email', var_export($ruim, true)); // --- A faixa do int --- echo "\n=== 5. FILTER_VALIDATE_INT com min_range e max_range ===\n"; foreach (['-5', '0', '31', '200'] as $teste) { $r = filter_var($teste, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 120]]); printf(" %-5s -> %s\n", $teste, $r === false ? 'rejeitado' : (string) $r); } // --- htmlspecialchars --- echo "\n=== 6. htmlspecialchars na saida, nunca no lugar de validar ===\n"; printf(" bio original: %s\n", $dados['bio']); printf(" escapada: %s\n", htmlspecialchars($dados['bio'], ENT_QUOTES, 'UTF-8')); echo "\n Escapar e para a tela. Sanitizar e para guardar: sao coisas diferentes.\n"; // --- Os filtros mais usados --- echo "\n=== 7. Filtros de filter_var ===\n"; $filtros = [ 'FILTER_VALIDATE_EMAIL' => 'endereco de e-mail', 'FILTER_VALIDATE_INT' => 'inteiro, com min_range e max_range', 'FILTER_VALIDATE_FLOAT' => 'real, com options de min/max', 'FILTER_VALIDATE_URL' => 'endereco http ou https', 'FILTER_VALIDATE_IP' => 'endereco IPv4 ou IPv6', 'FILTER_VALIDATE_DATE' => 'data, no formato declarado em options', 'FILTER_SANITIZE_STRING'=> 'remove tags, mas nao substitui validar', ]; foreach ($filtros as $filtro => $uso) { printf(" %-24s %s\n", $filtro, $uso); }
Saída real
=== 1. Lendo com seguranca o que chegou ===
<?php
declare(strict_types=1);
$email = trim($_POST['email'] ?? '');
$nota = $_POST['nota'] ?? '';
if ($email === '' || $nota === '') {
exit('Preencha os dois campos.');
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
exit('E-mail invalido.');
}
$nota = filter_var($nota, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 10]]);
if ($nota === false) {
exit('Nota invalida.');
}
// Texto que vem do usuario vai para a tela escapado.
echo htmlspecialchars($_POST['nome'] ?? '', ENT_QUOTES, 'UTF-8');
=== 2. As tres superglobais do formulario ===
$_GET dados de method="get", pela query string
$_POST dados de method="post", pelo corpo da requisicao
$_REQUEST a uniao das duas, na ordem GET, POST, COOKIE
Use $_POST e $_GET. $_REQUEST mistura as duas e esconde a origem do valor.
=== 3. trim antes de validar ===
original: [ Ana Souza ]
trim: [Ana Souza]
o espaco da borda quebraria a validacao de tamanho e o indice.
=== 4. filter_var: validacao de verdade ===
filter_var devolve o valor convertido, ou false quando nao serve.
FILTER_VALIDATE_EMAIL [email protected] '[email protected]'
FILTER_VALIDATE_INT 31 31
FILTER_VALIDATE_URL http://exemplo.com.br 'http://exemplo.com.br'
valor invalido nao e email false
=== 5. FILTER_VALIDATE_INT com min_range e max_range ===
-5 -> rejeitado
0 -> 0
31 -> 31
200 -> rejeitado
=== 6. htmlspecialchars na saida, nunca no lugar de validar ===
bio original: Ana; <b>aluna</b> de Eletronica &-formatacao
escapada: Ana; <b>aluna</b> de Eletronica &-formatacao
Escapar e para a tela. Sanitizar e para guardar: sao coisas diferentes.
=== 7. Filtros de filter_var ===
FILTER_VALIDATE_EMAIL endereco de e-mail
FILTER_VALIDATE_INT inteiro, com min_range e max_range
FILTER_VALIDATE_FLOAT real, com options de min/max
FILTER_VALIDATE_URL endereco http ou https
FILTER_VALIDATE_IP endereco IPv4 ou IPv6
FILTER_VALIDATE_DATE data, no formato declarado em options
FILTER_SANITIZE_STRING remove tags, mas nao substitui validar
Transações
Transação é o bloco de operações que só vale se todas derem certo. Sem ela, um INSERT que passa e um UPDATE que falha deixam o banco inconsistente. Com ela, ou grava tudo, ou não grava nada.
Os três métodos
| Método | O que faz |
|---|---|
beginTransaction(): bool | abre a transação na conexão |
commit(): bool | grava as pendências de vez |
rollBack(): bool | desfaz tudo desde o beginTransaction() |
try { $pdo->beginTransaction(); $pdo->prepare('UPDATE contas SET saldo = saldo - :valor WHERE id = :de') ->execute([':valor' => 150.00, ':de' => 1]); $pdo->prepare('UPDATE contas SET saldo = saldo + :valor WHERE id = :para') ->execute([':valor' => 150.00, ':para' => 2]); $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); }
O exemplo é uma transferência: debitar uma conta e creditar outra. Se o crédito falhar, o débito tem de sumir junto, senão o dinheiro desaparece.
O padrão completo
O beginTransaction() e o commit() andam dentro do mesmo try, e o rollBack() no catch. O catch é do tipo Throwable, não só de PDOException: uma validação que lance InvalidArgumentException no meio do bloco também precisa desfazer o que já foi gravado.
O finally é o lugar do log: ele roda com sucesso ou com falha, e registra o que aconteceu.
Regras que evitam problema
- A transação vale para a conexão, não para o objeto
PDO. Duas conexões diferentes não dividem transação. - Só tem efeito em tabela transacional.
ENGINE=InnoDBtransaciona;ENGINE=MyISAMignora e não dá para desfazer. - Não chama
query()do MySQL durante a transação. Oquery()do MySQL faz commit implícito e fecha a transação que estava aberta. - Sem
try/catch, um erro deixa a transação pendente, a tabela travada e a próxima requisição esperando. - Transação longa segura trava por muito tempo. Grave o que já está pronto e commit o antes possível.
Exemplo
<?php declare(strict_types=1); // Transacao: beginTransaction, commit e rollBack. // // Roda em SQLite porque a imagem do gerador nao tem pdo_mysql. A API de // transacao e identica nos dois drivers: beginTransaction() no comeco, // commit() no fim, rollBack() quando algo da errado. echo "=== 1. A regra da transacao ===\n"; echo " beginTransaction() -> as alteracoes ficam pendentes\n"; echo " commit() -> as pendencias viram definitivas\n"; echo " rollBack() -> as pendencias somem, o banco volta ao inicio\n\n"; echo " Sem transacao, um INSERT que roda e um UPDATE que falha deixam\n"; echo " o banco pela metade. Com transacao, ou grava tudo ou nao grava nada.\n"; // --- O caso que justifica --- echo "\n=== 2. O caso: transferencia entre duas contas ===\n"; $pdo = new PDO('sqlite::memory:', null, null, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, ]); $pdo->exec('CREATE TABLE contas (id INTEGER PRIMARY KEY, titular TEXT, saldo REAL)'); $pdo->exec("INSERT INTO contas (id, titular, saldo) VALUES (1, 'Ana', 500.00), (2, 'Bruno', 100.00)"); function saldos(PDO $pdo): array { $declarado = $pdo->query('SELECT titular, saldo FROM contas ORDER BY id')->fetchAll(PDO::FETCH_ASSOC); $texto = []; foreach ($declarado as $linha) { $texto[] = sprintf('%s R$ %6.2f', $linha['titular'], $linha['saldo']); } return $texto; } echo " antes: " . implode(' | ', saldos($pdo)) . "\n"; // --- Transacao que da certo --- echo "\n=== 3. Transacao que confirma (commit) ===\n"; try { $pdo->beginTransaction(); $debito = $pdo->prepare('UPDATE contas SET saldo = saldo - :valor WHERE id = :id'); $debito->execute([':valor' => 150.00, ':id' => 1]); $credito = $pdo->prepare('UPDATE contas SET saldo = saldo + :valor WHERE id = :id'); $credito->execute([':valor' => 150.00, ':id' => 2]); $pdo->commit(); echo " commit: transferencia gravada\n"; } catch (Throwable $e) { $pdo->rollBack(); echo ' rollBack: ' . $e->getMessage() . "\n"; } echo " depois: " . implode(' | ', saldos($pdo)) . "\n"; // --- Transacao que falha no meio --- echo "\n=== 4. Transacao que falha no meio (rollBack) ===\n"; try { $pdo->beginTransaction(); $pdo->prepare('UPDATE contas SET saldo = saldo - :valor WHERE id = :id') ->execute([':valor' => 400.00, ':id' => 1]); // ja saiu do saldo // A tabela nao tem a coluna 'obs': o banco recusa esta frase e o PHP lanca. $pdo->prepare('INSERT INTO contas (id, titular, saldo, obs) VALUES (3, :titular, 0, :obs)') ->execute([':titular' => 'Carla', ':obs' => 'abertura']); $pdo->commit(); echo " commit (nao chegou aqui)\n"; } catch (PDOException $e) { $pdo->rollBack(); echo ' rollBack: ' . $e->getMessage() . "\n"; } echo " depois: " . implode(' | ', saldos($pdo)) . "\n"; echo " O debito de 400,00 sumiu junto. Nenhum saldo ficou pela metade.\n"; // --- O que ja esta em transacao --- echo "\n=== 5. Duas tabelas InnoDB, uma transacao so ===\n"; echo " beginTransaction vale para todas as consultas feitas na mesma conexao.\n"; echo " So tem efeito em tabela transacional: MyISAM ignora e nao da para\n"; echo " desfazer. Todo CREATE TABLE deste curso usa ENGINE=InnoDB.\n"; // --- O erro esquecido --- echo "\n=== 6. commit sem try/catch deixa a transacao aberta ===\n"; echo " Se o erro nao for capturado, o rollBack nunca roda. A transacao fica\n"; echo " pendente, o banco trava a tabela e a proxima requisicao trava junto.\n"; echo " Por isso o try/catch em volta de beginTransaction e commit e regra.\n";
Saída real
=== 1. A regra da transacao === beginTransaction() -> as alteracoes ficam pendentes commit() -> as pendencias viram definitivas rollBack() -> as pendencias somem, o banco volta ao inicio Sem transacao, um INSERT que roda e um UPDATE que falha deixam o banco pela metade. Com transacao, ou grava tudo ou nao grava nada. === 2. O caso: transferencia entre duas contas === antes: Ana R$ 500.00 | Bruno R$ 100.00 === 3. Transacao que confirma (commit) === commit: transferencia gravada depois: Ana R$ 350.00 | Bruno R$ 250.00 === 4. Transacao que falha no meio (rollBack) === rollBack: SQLSTATE[HY000]: General error: 1 table contas has no column named obs depois: Ana R$ 350.00 | Bruno R$ 250.00 O debito de 400,00 sumiu junto. Nenhum saldo ficou pela metade. === 5. Duas tabelas InnoDB, uma transacao so === beginTransaction vale para todas as consultas feitas na mesma conexao. So tem efeito em tabela transacional: MyISAM ignora e nao da para desfazer. Todo CREATE TABLE deste curso usa ENGINE=InnoDB. === 6. commit sem try/catch deixa a transacao aberta === Se o erro nao for capturado, o rollBack nunca roda. A transacao fica pendente, o banco trava a tabela e a proxima requisicao trava junto. Por isso o try/catch em volta de beginTransaction e commit e regra.
Upload de arquivos
Upload é o envio de arquivo por formulário. O formulário precisa de enctype="multipart/form-data", e o PHP monta o array $_FILES, com uma entrada por campo de arquivo.
O formulário e o que chega
<form action="/receber.php" method="post" enctype="multipart/form-data"> <input type="file" name="foto" accept="image/jpeg,image/png" required> <button type="submit">Enviar</button> </form>
Sem o enctype, o campo de arquivo não é enviado e $_FILES fica vazio. É o erro mais comum de upload.
Chave em $_FILES | O que é |
|---|---|
name | nome que a pessoa tinha no computador dela |
type | tipo declarado pelo navegador |
tmp_name | caminho do arquivo temporário criado pelo PHP |
error | 0 quando deu certo, outro valor quando falhou |
size | bytes recebidos |
O tmp_name só existe até o fim da requisição. Se o arquivo não for movido antes, ele some.
Os códigos de error
UPLOAD_ERR_OK é 0. Os outros: UPLOAD_ERR_INI_SIZE (maior que upload_max_filesize), UPLOAD_ERR_FORM_SIZE (maior que o MAX_FILE_SIZE do formulário), UPLOAD_ERR_PARTIAL (chegou parte), UPLOAD_ERR_NO_FILE (nenhum escolhido), UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE e UPLOAD_ERR_EXTENSION.
Conferir o error antes de qualquer outra coisa evita tentar gravar um arquivo que não chegou.
A ordem da checagem
if ($_FILES['foto']['error'] !== UPLOAD_ERR_OK) { exit('Falha no envio.'); } $tmp = $_FILES['foto']['tmp_name']; if (!is_uploaded_file($tmp)) { exit('Arquivo invalido.'); } $permitidos = ['image/jpeg' => 'jpg', 'image/png' => 'png']; $dados = getimagesize($tmp); $tipoReal = $dados === false ? '' : $dados['mime']; if (!isset($permitidos[$tipoReal])) { exit('Formato nao aceito.'); } $destino = __DIR__ . '/uploads/' . bin2hex(random_bytes(8)) . '.' . $permitidos[$tipoReal]; if (!move_uploaded_file($tmp, $destino)) { exit('Nao foi possivel gravar o arquivo.'); }
is_uploaded_file(string $caminho): bool garante que o arquivo veio de um envio, e não de um caminho forjado no parâmetro da URL. move_uploaded_file(string $origem, string $destino): bool faz a movimentação e é a única função autorizada para isso.
getimagesize(string $arquivo): array|false lê o conteúdo do arquivo e devolve largura, altura e o tipo verdadeiro. O type que veio do navegador é controlado pelo cliente e não serve para decisão de segurança: o foto.png pode ser um script com outra extensão.
O nome final é gerado no servidor. minha foto; rm -rf.png é um nome de arquivo válido no Windows, e usar o nome que chegou traz o problema junto. O nome original, se for preciso, vai para o banco como texto, para download depois.
Exemplo
<?php declare(strict_types=1); // Upload de arquivos. // // Um upload precisa de um formulario postado por um navegador, entao o HTML e a // leitura de $_FILES aparecem como texto. O que roda de verdade aqui embaixo // sao as funcoes que decidem sobre o arquivo, aplicadas a um array com a mesma // forma de $_FILES, e o is_uploaded_file, que roda sobre um arquivo comum. echo "=== 1. O formulario de upload tem tres atributos a mais ===\n"; $html = <<<'HTML' <form action="/receber.php" method="post" enctype="multipart/form-data"> <label for="foto">Foto do aluno</label> <input type="file" id="foto" name="foto" accept="image/jpeg,image/png" required> <button type="submit">Enviar</button> </form> HTML; echo $html . "\n"; echo " enctype=\"multipart/form-data\" e obrigatorio. Sem ele, o PHP\n"; echo " recebe um \$_POST vazio e o campo file nem existe em \$_FILES.\n"; // --- $_FILES --- echo "\n=== 2. O que o PHP monta em \$_FILES ===\n"; $arquivo = [ 'name' => 'ana.png', // nome que veio do cliente: nao confiavel 'full_path'=> 'C:\\Users\\ana\\Desktop\\ana.png', 'type' => 'image/png', // tambem declarado pelo cliente 'tmp_name' => '/tmp/php9xK2a', // arquivo temporario criado pelo PHP 'error' => 0, // UPLOAD_ERR_OK 'size' => 48210, ]; $rotulos = [ 'name' => 'nome que a pessoa digitou no computador dela', 'type' => 'tipo declarado no navegador, nao verificado', 'tmp_name' => 'caminho temporario; some no fim da requisicao', 'error' => '0 (UPLOAD_ERR_OK) quando o upload deu certo', 'size' => 'bytes recebidos', ]; foreach ($rotulos as $chave => $descricao) { printf(" %-9s %-42s %s\n", $chave, (string) $arquivo[$chave], $descricao); } // --- Os codigos de erro --- echo "\n=== 3. O que o campo error pode valer ===\n"; $erros = [ 'UPLOAD_ERR_OK' => '0 deu certo', 'UPLOAD_ERR_INI_SIZE' => '1 maior que upload_max_filesize do php.ini', 'UPLOAD_ERR_FORM_SIZE' => '2 maior que o MAX_FILE_SIZE do formulario', 'UPLOAD_ERR_PARTIAL' => '3 chegou so parte do arquivo', 'UPLOAD_ERR_NO_FILE' => '4 nenhum arquivo escolhido', 'UPLOAD_ERR_NO_TMP_DIR' => '6 falta pasta temporaria no servidor', 'UPLOAD_ERR_CANT_WRITE' => '7 o PHP nao conseguiu gravar no disco', 'UPLOAD_ERR_EXTENSION' => '8 uma extensao do PHP bloqueou o upload', ]; foreach ($erros as $constante => $descricao) { printf(" %-22s %s\n", $constante, $descricao); } // --- Os dois limites do servidor --- echo "\n=== 4. Onde os limites sao definidos ===\n"; echo " upload_max_filesize = 8M (php.ini, tamanho maximo de um arquivo)\n"; echo " post_max_size = 10M (php.ini, tamanho de todo o POST)\n"; echo " MAX_FILE_SIZE=8388608 (campo hidden, checado pelo PHP antes de gravar)\n"; echo " Se o POST inteiro passar de post_max_size, o PHP zera \$_POST e \$_FILES.\n"; // --- A checagem real --- echo "\n=== 5. A checagem, na ordem em que precisa acontecer ===\n"; $codigo = <<<'PHP' <?php declare(strict_types=1); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { exit('Envio invalido.'); } if (!isset($_FILES['foto']) || $_FILES['foto']['error'] !== UPLOAD_ERR_OK) { exit('Nenhum arquivo recebido.'); } $tmp = $_FILES['foto']['tmp_name']; // Um arquivo enviado nao vem por URL: o PHP exige este teste antes de gravar. if (!is_uploaded_file($tmp)) { exit('Arquivo invalido.'); } $tamanho = $_FILES['foto']['size']; $permitidos = ['image/jpeg' => 'jpg', 'image/png' => 'png']; // getimagesize olha o conteudo do arquivo e devolve o tipo verdadeiro. // type enviado pelo navegador nao serve para decisao de seguranca. $dados = getimagesize($tmp); $tipoReal = $dados === false ? '' : $dados['mime']; if (!isset($permitidos[$tipoReal])) { exit('Formato nao aceito.'); } $destino = __DIR__ . '/uploads/' . uniqid('', true) . '.' . $permitidos[$tipoReal]; if (!move_uploaded_file($tmp, $destino)) { exit('Falha ao gravar o arquivo.'); } PHP; echo $codigo . "\n"; // --- move_uploaded_file de verdade --- echo "\n=== 6. move_uploaded_file em acao ===\n"; $tmp = tempnam(sys_get_temp_dir(), 'aula'); $origem = tempnam(sys_get_temp_dir(), 'orig'); file_put_contents($origem, "\x89PNG\r\n\x1a\n" . 'conteudo de teste para a aula'); rename($origem, $tmp); $destino = sys_get_temp_dir() . '/copia-da-aula.png'; // is_uploaded_file responde false fora de uma requisicao HTTP: e assim que o // exemplo mostra que a funcao existe e o que ela devolve neste contexto. printf(" is_uploaded_file no arquivo criado aqui: %s\n", var_export(is_uploaded_file($tmp), true)); // move_uploaded_file tambem recusa fora de upload, entao o exemplo usa a // funcao real do PHP apenas com rename, que e o que roda em linha de comando. if (rename($tmp, $destino)) { printf(" arquivo movido para: %s (%d bytes)\n", basename($destino), filesize($destino)); printf(" getimagesize leu: %s\n", getimagesize($destino)['mime']); } unlink($destino); // --- Nome do arquivo --- echo "\n=== 7. O nome que volta do cliente nao serve ===\n"; echo " 'minha foto; rm -rf.png' e um nome de arquivo valido no Windows.\n"; echo " Gere o nome no servidor: uniqid(), bin2hex(random_bytes(8)) ou o id do\n"; echo " registro no banco. O nome original so vai para o banco, como texto.\n";
Saída real
=== 1. O formulario de upload tem tres atributos a mais ===
<form action="/receber.php" method="post" enctype="multipart/form-data">
<label for="foto">Foto do aluno</label>
<input type="file" id="foto" name="foto" accept="image/jpeg,image/png" required>
<button type="submit">Enviar</button>
</form>
enctype="multipart/form-data" e obrigatorio. Sem ele, o PHP
recebe um $_POST vazio e o campo file nem existe em $_FILES.
=== 2. O que o PHP monta em $_FILES ===
name ana.png nome que a pessoa digitou no computador dela
type image/png tipo declarado no navegador, nao verificado
tmp_name /tmp/php9xK2a caminho temporario; some no fim da requisicao
error 0 0 (UPLOAD_ERR_OK) quando o upload deu certo
size 48210 bytes recebidos
=== 3. O que o campo error pode valer ===
UPLOAD_ERR_OK 0 deu certo
UPLOAD_ERR_INI_SIZE 1 maior que upload_max_filesize do php.ini
UPLOAD_ERR_FORM_SIZE 2 maior que o MAX_FILE_SIZE do formulario
UPLOAD_ERR_PARTIAL 3 chegou so parte do arquivo
UPLOAD_ERR_NO_FILE 4 nenhum arquivo escolhido
UPLOAD_ERR_NO_TMP_DIR 6 falta pasta temporaria no servidor
UPLOAD_ERR_CANT_WRITE 7 o PHP nao conseguiu gravar no disco
UPLOAD_ERR_EXTENSION 8 uma extensao do PHP bloqueou o upload
=== 4. Onde os limites sao definidos ===
upload_max_filesize = 8M (php.ini, tamanho maximo de um arquivo)
post_max_size = 10M (php.ini, tamanho de todo o POST)
MAX_FILE_SIZE=8388608 (campo hidden, checado pelo PHP antes de gravar)
Se o POST inteiro passar de post_max_size, o PHP zera $_POST e $_FILES.
=== 5. A checagem, na ordem em que precisa acontecer ===
<?php
declare(strict_types=1);
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
exit('Envio invalido.');
}
if (!isset($_FILES['foto']) || $_FILES['foto']['error'] !== UPLOAD_ERR_OK) {
exit('Nenhum arquivo recebido.');
}
$tmp = $_FILES['foto']['tmp_name'];
// Um arquivo enviado nao vem por URL: o PHP exige este teste antes de gravar.
if (!is_uploaded_file($tmp)) {
exit('Arquivo invalido.');
}
$tamanho = $_FILES['foto']['size'];
$permitidos = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
// getimagesize olha o conteudo do arquivo e devolve o tipo verdadeiro.
// type enviado pelo navegador nao serve para decisao de seguranca.
$dados = getimagesize($tmp);
$tipoReal = $dados === false ? '' : $dados['mime'];
if (!isset($permitidos[$tipoReal])) {
exit('Formato nao aceito.');
}
$destino = __DIR__ . '/uploads/' . uniqid('', true) . '.' . $permitidos[$tipoReal];
if (!move_uploaded_file($tmp, $destino)) {
exit('Falha ao gravar o arquivo.');
}
=== 6. move_uploaded_file em acao ===
is_uploaded_file no arquivo criado aqui: false
arquivo movido para: copia-da-aula.png (37 bytes)
getimagesize leu: image/png
=== 7. O nome que volta do cliente nao serve ===
'minha foto; rm -rf.png' e um nome de arquivo valido no Windows.
Gere o nome no servidor: uniqid(), bin2hex(random_bytes(8)) ou o id do
registro no banco. O nome original so vai para o banco, como texto.