Botão excluir em PHP e MySQL que apaga uma linha, não a tabela
O fluxo completo de exclusão: POST com token, id validado, DELETE com id e dono como parâmetros, conferência de linhas afetadas e exclusão lógica para poder desfazer.
Quinta, 17h40, a dona da confeitaria liga: "sumiram todos os pedidos da semana". O botão excluir da lista de pedidos montava DELETE FROM pedidos WHERE id = $id, e uma requisição chegou com o id vazio. Dependendo de como o texto foi montado, o resultado é erro de sintaxe, nenhuma linha, ou todas. Ali foram todas. A resposta curta: exclusão é um POST com token, o id é validado como inteiro, o DELETE leva o id e o dono do registro como parâmetros, o código confere quantas linhas foram afetadas, e o que precisa poder voltar recebe exclusão lógica em vez de DELETE.
Por que um DELETE sem condição apaga tudo, e como isso acontece na prática, está em excluir no SQL sem critério apaga a tabela. Aqui o foco é construir o fluxo inteiro do botão, da tela até o banco, num sistema de pedidos de uma confeitaria.
Onde o botão excluir costuma falhar
O tutorial clássico faz um link excluir.php?id=15 e cola o valor na consulta. Isso tem quatro buracos. Link é GET, e GET pode ser disparado por uma imagem em outro site, por um pré-carregamento do navegador ou por um robô que segue links. O valor colado no SQL abre injeção: id=15 OR 1=1 vira uma condição verdadeira para todas as linhas. A consulta não confere de quem é o pedido, então qualquer conta logada apaga pedidos de qualquer outra trocando o número. E a tela mostra "excluído com sucesso" mesmo quando nada foi excluído.
Como montar o fluxo de exclusão
- Na lista, o botão é um formulário com
method="post", o id num campo e um token anti-CSRF gerado na sessão. - No servidor, recuse qualquer método que não seja POST.
- Confira o token com
hash_equals. - Valide o id com
filter_inputeFILTER_VALIDATE_INT; vazio, zero, texto e negativo param ali. - Execute a exclusão com o id e a conta da sessão como parâmetros.
- Use
rowCount()para saber se uma linha foi afetada e responda de acordo.
<?php // na lista de pedidos ?>
<form method="post" action="/pedidos/excluir.php"
onsubmit="return confirm('Excluir o pedido #<?= (int) $pedido['id'] ?>?');">
<input type="hidden" name="id" value="<?= (int) $pedido['id'] ?>">
<input type="hidden" name="csrf" value="<?= e($_SESSION['csrf']) ?>">
<button type="submit">Excluir</button>
</form>
<?php
// pedidos/excluir.php
require __DIR__ . '/../bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}
$token = $_POST['csrf'] ?? '';
if (!is_string($token) || !hash_equals($_SESSION['csrf'] ?? '', $token)) {
http_response_code(403);
exit('Sessão expirada. Recarregue a página.');
}
$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if (!$id) {
http_response_code(400);
exit('Pedido inválido.');
}
$stmt = $pdo->prepare(
'UPDATE pedidos SET apagado_em = NOW()
WHERE id = ? AND conta_id = ? AND apagado_em IS NULL'
);
$stmt->execute([$id, $_SESSION['conta_id']]);
if ($stmt->rowCount() === 1) {
$_SESSION['aviso'] = 'Pedido #' . $id . ' excluído. Você pode restaurá-lo em até 30 dias.';
} else {
$_SESSION['aviso'] = 'Esse pedido não existe ou já foi excluído.';
}
header('Location: /pedidos/');
exit;
O token da sessão é criado uma vez, no login ou no primeiro acesso, com bin2hex(random_bytes(32)). O is_string antes do hash_equals existe porque alguém pode mandar csrf[]=x, e o PHP entregaria um array, que faz hash_equals lançar erro. O min_range faz o filtro devolver false para zero e negativos, e o !$id pega tanto false quanto null (campo ausente).
Por que o dono entra na condição
O AND conta_id = ? é o que impede uma confeitaria de apagar pedidos de outra trocando o id no HTML. Sem ele, a autenticação diz quem é a pessoa, mas nada limita o que ela alcança. O valor vem da sessão, nunca do formulário. Se nenhuma linha foi afetada, a resposta não diz se o pedido existe em outra conta; diz só que não foi possível excluir.
Exclusão lógica: o desfazer que salva a semana
O exemplo acima não usa DELETE. Ele preenche a coluna apagado_em, e todas as listas passam a ignorar essas linhas. Restaurar vira limpar a coluna. A estrutura é pequena:
ALTER TABLE pedidos ADD COLUMN apagado_em DATETIME NULL;
CREATE INDEX idx_pedidos_conta_apagado ON pedidos (conta_id, apagado_em);
-- todas as listas
SELECT id, cliente, total_centavos, criado_em
FROM pedidos
WHERE conta_id = ? AND apagado_em IS NULL
ORDER BY criado_em DESC
LIMIT 20;
-- restaurar
UPDATE pedidos SET apagado_em = NULL WHERE id = ? AND conta_id = ?;
A exclusão definitiva fica para um job agendado, que remove o que passou do prazo, em lotes, depois de existir backup:
DELETE FROM pedidos
WHERE apagado_em IS NOT NULL
AND apagado_em < NOW() - INTERVAL 30 DAY
LIMIT 1000;
Dois cuidados. Restrições UNIQUE continuam valendo para linhas apagadas logicamente, então um código de cupom "excluído" ainda bloqueia o mesmo código novo; planeje isso no modelo. E exclusão lógica não é exclusão para fins de privacidade: se a pessoa pediu para apagar os dados dela, o prazo e o backup entram na conta, como explica apagar a conta na tela e manter no backup não é apagar.
Chaves estrangeiras com ON DELETE CASCADE
Antes de qualquer DELETE definitivo, confira as chaves estrangeiras da tabela. Um ON DELETE CASCADE nos itens do pedido é esperado. Um cascade que alguém colocou na tabela de clientes, apontando para pedidos, transforma "excluir um cliente de teste" em "apagar o histórico de vendas dele".
Quando você mesmo vai rodar um DELETE no console
Correção manual em produção é onde o pior acontece. Três hábitos reduzem o risco:
- Rode antes um
SELECT COUNT(*)com exatamente o mesmoWHEREe confira o número. - Abra uma transação, execute, confira as linhas afetadas e só então faça
COMMIT(em tabelas InnoDB). - Ligue o modo seguro da sessão: com
SET SQL_SAFE_UPDATES = 1, o MySQL recusaUPDATEeDELETEsem condição em coluna de chave.
SET SQL_SAFE_UPDATES = 1;
START TRANSACTION;
DELETE FROM pedidos WHERE id = 4815 AND conta_id = 12;
-- Query OK, 1 row affected
COMMIT;
Se a mensagem disser "3200 rows affected" quando você esperava uma, o comando seguinte é ROLLBACK, não pânico. E se a transação já foi confirmada, a salvação é um backup que você já testou restaurar, como em backup de MySQL que você consegue restaurar de verdade.
Erros comuns
- Excluir por link GET. Robôs, pré-carregamento e imagens em outros sites disparam a exclusão sem clique.
- Colar o id no texto da consulta. Id vazio ou com
OR 1=1amplia a condição até a tabela inteira. - Não incluir o dono na condição. Qualquer conta logada apaga registros de outras trocando um número.
- Mostrar sucesso sem olhar
rowCount(). A pessoa acredita que excluiu, e o registro continua lá, ou nunca existiu. - Apagar de verdade o que o negócio precisa recuperar. Um clique errado no celular vira uma restauração de backup inteira.
- Rodar
DELETEmanual sem transação. UmWHEREincompleto não tem volta.
Checklist
- O botão excluir é um formulário POST com token anti-CSRF.
- O id é validado como inteiro positivo antes de chegar ao banco.
- A consulta usa parâmetros e inclui o dono do registro, vindo da sessão.
- A resposta depende de
rowCount(). - Registros recuperáveis têm exclusão lógica, e as listas filtram
apagado_em IS NULL. - A exclusão definitiva roda num job, em lotes, depois do prazo e do backup.
- Correções manuais usam transação e modo seguro.
Abra o arquivo que trata o seu botão excluir e responda: se alguém mandar o mesmo pedido com o id de um registro de outra conta, o que impede a exclusão, uma linha do seu código ou a sorte?
Referência: http://fazer-site.net/excluir-registros-no-banco-mysql-delete-from-sintaxe/