Dia 14 — Formulários e transações: PHP e MySQL

Informatica · Conteudo · publicado em 02/10/2026
Dia 14 de 17

Formulários e transações

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`.

Aula 1

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

GETPOST
Os dados ficamna URL, depois do ?no corpo da requisição
Aparece no históricosimnão
Recarregar a páginarepete a busca, sem duplicar nadapode reenviar o envio
Tamanholimitado pelo navegador, na casa dos 2 mil caractereslimitado por post_max_size
Serve parabuscar, filtrar, paginargravar, 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
Aula 2

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.

FiltroO que devolve
FILTER_VALIDATE_EMAILo e-mail, ou false
FILTER_VALIDATE_INTo inteiro, ou false
FILTER_VALIDATE_FLOATo real, ou false
FILTER_VALIDATE_URLa URL, ou false
FILTER_VALIDATE_IPo 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 &lt; 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; &lt;b&gt;aluna&lt;/b&gt; de Eletronica &amp;-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
Aula 3

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étodoO que faz
beginTransaction(): boolabre a transação na conexão
commit(): boolgrava as pendências de vez
rollBack(): booldesfaz 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=InnoDB transaciona; ENGINE=MyISAM ignora e não dá para desfazer.
  • Não chama query() do MySQL durante a transação. O query() 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.
Aula 4

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 $_FILESO que é
namenome que a pessoa tinha no computador dela
typetipo declarado pelo navegador
tmp_namecaminho do arquivo temporário criado pelo PHP
error0 quando deu certo, outro valor quando falhou
sizebytes 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.