delete 9. CRUD Avançado - DELETE
A operação DELETE é a mais perigosa do CRUD. Uma vez executada incorretamente, dados podem ser perdidos permanentemente. Em aplicações profissionais, raramente se usa "hard delete" (remover fisicamente do banco). Ao invés disso, implementa-se soft delete (marcar como deletado com flag), permitindo recuperação e mantendo integridade referencial.
Além da escolha entre soft/hard delete, você precisa decidir sobre cascata: quando um post é deletado, o que acontece com seus comentários? E com uploads de imagem? E referências em outras tabelas? O ON DELETE CASCADE do SQL automatiza isso, mas pode causar perdas inesperadas de dados.
Nesta seção, você aprenderá a implementar soft delete com lixeira (usuários podem recuperar por 30 dias), hard delete com confirmação, bulk delete com transações e auditoria completa de quem deletou o quê e quando.
compare_arrows Soft Delete vs Hard Delete
visibility_off Soft Delete (Recomendado)
UPDATE posts
SET deleted_at = NOW(),
deleted_by = 123
WHERE id = 456;
Prós:
• Recuperação possível
• Mantém integridade referencial
• Auditoria completa
• Conformidade LGPD/GDPR
Contras:
• Banco cresce indefinidamente
• Queries mais complexas (WHERE deleted_at IS NULL)
delete_forever Hard Delete (Cuidado!)
DELETE FROM posts WHERE id = 456;
Prós:
• Libera espaço em disco
• Queries mais simples
Contras:
• IRREVERSÍVEL
• Pode quebrar foreign keys
• Perde histórico
• Pode violar LGPD (direito ao esquecimento requer prova)
deleted_at < NOW() - INTERVAL 30 DAY.
code Implementando Soft Delete
1️⃣ Estrutura da Tabela
-- Adicionar colunas de soft delete ALTER TABLE posts ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL, ADD COLUMN deleted_by INT NULL, ADD INDEX idx_deleted_at (deleted_at); -- Foreign key para rastrear quem deletou ALTER TABLE posts ADD CONSTRAINT fk_deleted_by FOREIGN KEY (deleted_by) REFERENCES users(id);
2️⃣ Endpoint de Soft Delete
prepare('
SELECT user_id, deleted_at
FROM posts
WHERE id = ?
');
$stmt->execute([$postId]);
$post = $stmt->fetch();
if (!$post) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Post não encontrado']);
exit;
}
if ($post['deleted_at']) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => 'Post já foi deletado']);
exit;
}
// Verificar permissão
$stmt = $pdo->prepare('SELECT role FROM users WHERE id = ?');
$stmt->execute([$userId]);
$userRole = $stmt->fetchColumn();
if ($post['user_id'] != $userId && $userRole !== 'admin') {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Sem permissão']);
exit;
}
// SOFT DELETE
$stmt = $pdo->prepare('
UPDATE posts
SET deleted_at = NOW(),
deleted_by = ?
WHERE id = ?
');
$stmt->execute([$userId, $postId]);
// Log de auditoria
$pdo->prepare('
INSERT INTO audit_logs (user_id, action, table_name, record_id, created_at)
VALUES (?, ?, ?, ?, NOW())
')->execute([$userId, 'DELETE', 'posts', $postId]);
echo json_encode([
'success' => true,
'message' => 'Post movido para lixeira',
'recoverable_until' => date('Y-m-d H:i:s', strtotime('+30 days'))
]);
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
?>
3️⃣ Recuperar da Lixeira (Restore)
prepare('
SELECT user_id, deleted_at, deleted_by
FROM posts
WHERE id = ? AND deleted_at IS NOT NULL
');
$stmt->execute([$postId]);
$post = $stmt->fetch();
if (!$post) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Post não encontrado na lixeira']);
exit;
}
// Verificar se quem deletou ou admin
$stmt = $pdo->prepare('SELECT role FROM users WHERE id = ?');
$stmt->execute([$userId]);
$userRole = $stmt->fetchColumn();
if ($post['deleted_by'] != $userId && $userRole !== 'admin') {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Apenas quem deletou pode restaurar']);
exit;
}
// RESTAURAR
$stmt = $pdo->prepare('
UPDATE posts
SET deleted_at = NULL,
deleted_by = NULL
WHERE id = ?
');
$stmt->execute([$postId]);
// Log
$pdo->prepare('
INSERT INTO audit_logs (user_id, action, table_name, record_id, created_at)
VALUES (?, ?, ?, ?, NOW())
')->execute([$userId, 'RESTORE', 'posts', $postId]);
echo json_encode(['success' => true, 'message' => 'Post restaurado com sucesso']);
} catch (Exception $e) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
?>
delete_sweep Hard Delete Permanente
prepare('SELECT role FROM users WHERE id = ?');
$stmt->execute([$userId]);
if ($stmt->fetchColumn() !== 'admin') {
http_response_code(403);
echo json_encode(['success' => false, 'message' => 'Apenas administradores']);
exit;
}
// Verificar se post existe
$stmt = $pdo->prepare('SELECT * FROM posts WHERE id = ?');
$stmt->execute([$postId]);
$post = $stmt->fetch();
if (!$post) {
http_response_code(404);
echo json_encode(['success' => false, 'message' => 'Post não encontrado']);
exit;
}
// Iniciar TRANSAÇÃO (atomicidade)
$pdo->beginTransaction();
try {
// 1. Deletar comentários associados
$stmt = $pdo->prepare('DELETE FROM comments WHERE post_id = ?');
$stmt->execute([$postId]);
$deletedComments = $stmt->rowCount();
// 2. Deletar imagens (e arquivos físicos)
$stmt = $pdo->prepare('SELECT filename FROM images WHERE post_id = ?');
$stmt->execute([$postId]);
$images = $stmt->fetchAll(PDO::FETCH_COLUMN);
foreach ($images as $filename) {
$filepath = __DIR__ . '/../../uploads/images/' . $filename;
if (file_exists($filepath)) {
unlink($filepath);
}
}
$stmt = $pdo->prepare('DELETE FROM images WHERE post_id = ?');
$stmt->execute([$postId]);
$deletedImages = $stmt->rowCount();
// 3. Deletar post (IRREVERSÍVEL!)
$stmt = $pdo->prepare('DELETE FROM posts WHERE id = ?');
$stmt->execute([$postId]);
// 4. Log de auditoria (ANTES de commit!)
$pdo->prepare('
INSERT INTO audit_logs (user_id, action, table_name, record_id, details, created_at)
VALUES (?, ?, ?, ?, ?, NOW())
')->execute([
$userId,
'HARD_DELETE',
'posts',
$postId,
json_encode([
'title' => $post['title'],
'deleted_comments' => $deletedComments,
'deleted_images' => $deletedImages
])
]);
// COMMIT - tornar permanente
$pdo->commit();
echo json_encode([
'success' => true,
'message' => 'Post deletado permanentemente',
'details' => [
'deleted_comments' => $deletedComments,
'deleted_images' => $deletedImages
]
]);
} catch (Exception $e) {
// ROLLBACK em caso de erro
$pdo->rollBack();
throw $e;
}
} catch (Exception $e) {
error_log("Hard Delete Error: " . $e->getMessage());
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Erro ao deletar']);
}
?>
BEGIN TRANSACTION
e COMMIT para garantir atomicidade. Se deletar o post mas falhar ao deletar
comentários, a transação faz ROLLBACK automático, deixando tudo intacto.
storage Estratégias de Cascata
linear_scale CASCADE
ON DELETE CASCADE
Deleta automaticamente registros relacionados. Perigoso - pode causar deleções em massa não intencionais.
block RESTRICT
ON DELETE RESTRICT
Bloqueia delete se há registros relacionados. Seguro - força limpeza manual.
settings_backup_restore SET NULL
ON DELETE SET NULL
Seta foreign key como NULL. Útil para manter comentários após deletar post.
-- Exemplo de foreign key com estratégia ALTER TABLE comments ADD CONSTRAINT fk_post_id FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE; -- Deletar post = deletar comentários ALTER TABLE posts ADD CONSTRAINT fk_category_id FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT; -- Não permite deletar categoria com posts
schedule Job de Limpeza Automática
prepare('
SELECT id, title FROM posts
WHERE deleted_at IS NOT NULL
AND deleted_at < NOW() - INTERVAL 30 DAY
');
$stmt->execute();
$postsToDelete = $stmt->fetchAll();
$count = 0;
foreach ($postsToDelete as $post) {
try {
$pdo->beginTransaction();
// Deletar relacionados
$pdo->prepare('DELETE FROM comments WHERE post_id = ?')->execute([$post['id']]);
$pdo->prepare('DELETE FROM images WHERE post_id = ?')->execute([$post['id']]);
// Hard delete
$pdo->prepare('DELETE FROM posts WHERE id = ?')->execute([$post['id']]);
// Log
$pdo->prepare('
INSERT INTO audit_logs (user_id, action, table_name, record_id, details, created_at)
VALUES (NULL, ?, ?, ?, ?, NOW())
')->execute(['AUTO_CLEANUP', 'posts', $post['id'], json_encode(['title' => $post['title']])]);
$pdo->commit();
$count++;
} catch (Exception $e) {
$pdo->rollBack();
error_log("Cleanup Error for post {$post['id']}: " . $e->getMessage());
}
}
echo "✅ Cleanup concluído: $count posts deletados permanentemente\n";
} catch (Exception $e) {
error_log("Cleanup Job Error: " . $e->getMessage());
exit(1);
}
?>
crontab -e:0 3 * * * /usr/bin/php /var/www/api/cron/cleanup-deleted-posts.php >> /var/log/cleanup.log 2>&1