Tema
CRUD completo
CRUD significa Create, Read, Update, Delete: crear, leer, actualizar y borrar. Es el corazón del 80 % de los sistemas que te pedirá un cliente: inventarios, directorios de clientes, agendas, catálogos. En esta lección construirás un CRUD completo de clientes con PHP puro, PDO, validación, mensajes flash y protección CSRF.
Concepto
Cada operación del CRUD se corresponde con una acción SQL y una pantalla:
| Operación | SQL | Pantalla | Método HTTP |
|---|---|---|---|
| Listar (Read) | SELECT | index.php | GET |
| Crear (Create) | INSERT | crear.php | GET (form) + POST (guardar) |
| Editar (Update) | UPDATE | editar.php?id=5 | GET (form) + POST (guardar) |
| Borrar (Delete) | DELETE | borrar.php | POST (nunca GET) |
Usaremos el patrón PRG (Post/Redirect/Get): después de procesar un POST, redirigimos a otra página con GET. Así, si el usuario recarga la página, no se reenvía el formulario ni se duplica el registro.
Estructura del proyecto
txt
crud-clientes/
├── config.php ← conexión y funciones comunes
├── index.php ← listado con búsqueda
├── crear.php ← formulario de alta
├── editar.php ← formulario de edición
├── borrar.php ← procesa el borrado (sólo POST)
├── _form.php ← campos del formulario (compartido por crear y editar)
├── _header.php
└── _footer.phpBase de datos
sql
CREATE DATABASE IF NOT EXISTS crud_clientes CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE crud_clientes;
CREATE TABLE clientes (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
telefono VARCHAR(20) NULL,
empresa VARCHAR(100) NULL,
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
actualizado_en DATETIME NULL ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
INSERT INTO clientes (nombre, email, telefono, empresa) VALUES
('María Fernández', 'maria@floreria.mx', '5512345678', 'Florería Lupita'),
('Jorge Ramírez', 'jorge@taller.com', '3312345678', 'Taller Ramírez');config.php: conexión y utilidades
php
<?php
// config.php
declare(strict_types=1);
session_start();
// --- Conexión PDO ---
function db(): PDO
{
static $pdo = null; // se conserva entre llamadas: una sola conexión por petición
if ($pdo === null) {
$pdo = new PDO(
'mysql:host=127.0.0.1;dbname=crud_clientes;charset=utf8mb4',
'root',
'',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
}
return $pdo;
}
// --- Escapar salida HTML (anti-XSS) ---
function e(?string $valor): string
{
return htmlspecialchars($valor ?? '', ENT_QUOTES, 'UTF-8');
}
// --- Redirigir y terminar ---
function redirigir(string $url): never
{
header("Location: $url");
exit;
}
// --- Mensajes flash: se muestran una sola vez ---
function flash(string $tipo, string $mensaje): void
{
$_SESSION['flash'] = ['tipo' => $tipo, 'mensaje' => $mensaje];
}
function obtenerFlash(): ?array
{
$f = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
return $f;
}
// --- Protección CSRF ---
function csrfToken(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function csrfCampo(): string
{
return '<input type="hidden" name="csrf" value="' . csrfToken() . '">';
}
function verificarCsrf(): void
{
$enviado = $_POST['csrf'] ?? '';
if (!hash_equals(csrfToken(), $enviado)) {
http_response_code(419);
exit('La sesión expiró. Recarga la página e inténtalo de nuevo.');
}
}
// --- Validación de un cliente ---
function validarCliente(array $datos, ?int $idActual = null): array
{
$errores = [];
if (mb_strlen($datos['nombre']) < 3) {
$errores['nombre'] = 'El nombre debe tener al menos 3 caracteres.';
}
if (!filter_var($datos['email'], FILTER_VALIDATE_EMAIL)) {
$errores['email'] = 'Escribe un email válido.';
} else {
// El email debe ser único (excepto el del propio cliente al editar)
$stmt = db()->prepare('SELECT id FROM clientes WHERE email = ? AND id <> ?');
$stmt->execute([$datos['email'], $idActual ?? 0]);
if ($stmt->fetch()) {
$errores['email'] = 'Ya existe un cliente con ese email.';
}
}
if ($datos['telefono'] !== '' && !preg_match('/^\+?[0-9 ]{7,20}$/', $datos['telefono'])) {
$errores['telefono'] = 'Teléfono inválido (sólo números, 7 a 20 dígitos).';
}
return $errores;
}
// Toma los campos de $_POST, limpios
function datosDelFormulario(): array
{
return [
'nombre' => trim($_POST['nombre'] ?? ''),
'email' => mb_strtolower(trim($_POST['email'] ?? '')),
'telefono' => trim($_POST['telefono'] ?? ''),
'empresa' => trim($_POST['empresa'] ?? ''),
];
}El tipo de retorno never (PHP 8.1) indica que la función nunca regresa: siempre termina con exit o una excepción.
Plantillas compartidas
php
<?php // _header.php ?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($titulo ?? 'Clientes') ?></title>
<style>
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: .6rem; border-bottom: 1px solid #ddd; text-align: left; }
.btn { display: inline-block; padding: .4rem .8rem; border-radius: 6px; background: #2563eb; color: #fff; text-decoration: none; border: 0; cursor: pointer; }
.btn.rojo { background: #dc2626; }
.flash { padding: .8rem; border-radius: 6px; margin-bottom: 1rem; }
.flash.ok { background: #dcfce7; } .flash.error { background: #fee2e2; }
.error { color: #dc2626; font-size: .9rem; }
label { display: block; margin-top: 1rem; }
input { width: 100%; padding: .5rem; box-sizing: border-box; }
</style>
</head>
<body>
<?php if ($f = obtenerFlash()): ?>
<div class="flash <?= e($f['tipo']) ?>"><?= e($f['mensaje']) ?></div>
<?php endif; ?>php
<?php // _footer.php ?>
</body>
</html>index.php: listar y buscar (Read)
php
<?php
// index.php
require __DIR__ . '/config.php';
$q = trim($_GET['q'] ?? '');
if ($q !== '') {
$stmt = db()->prepare(
'SELECT * FROM clientes
WHERE nombre LIKE :q OR email LIKE :q OR empresa LIKE :q
ORDER BY nombre'
);
$stmt->execute(['q' => "%$q%"]);
} else {
$stmt = db()->query('SELECT * FROM clientes ORDER BY creado_en DESC');
}
$clientes = $stmt->fetchAll();
$titulo = 'Clientes';
require __DIR__ . '/_header.php';
?>
<h1>Clientes (<?= count($clientes) ?>)</h1>
<p>
<a class="btn" href="crear.php">+ Nuevo cliente</a>
</p>
<form method="get">
<input type="search" name="q" value="<?= e($q) ?>" placeholder="Buscar por nombre, email o empresa">
</form>
<?php if (!$clientes): ?>
<p>No hay clientes<?= $q ? ' que coincidan con la búsqueda' : '' ?>.</p>
<?php else: ?>
<table>
<thead>
<tr><th>Nombre</th><th>Email</th><th>Teléfono</th><th>Empresa</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($clientes as $c): ?>
<tr>
<td><?= e($c['nombre']) ?></td>
<td><?= e($c['email']) ?></td>
<td><?= e($c['telefono']) ?></td>
<td><?= e($c['empresa']) ?></td>
<td style="white-space:nowrap">
<a class="btn" href="editar.php?id=<?= (int) $c['id'] ?>">Editar</a>
<!-- Borrar SIEMPRE con POST y token CSRF -->
<form method="post" action="borrar.php" style="display:inline"
onsubmit="return confirm('¿Borrar a <?= e($c['nombre']) ?>?')">
<?= csrfCampo() ?>
<input type="hidden" name="id" value="<?= (int) $c['id'] ?>">
<button class="btn rojo">Borrar</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php require __DIR__ . '/_footer.php'; ?>_form.php: campos reutilizables
php
<?php
// _form.php — espera las variables $cliente (array) y $errores (array)
?>
<?= csrfCampo() ?>
<label>Nombre *
<input name="nombre" value="<?= e($cliente['nombre']) ?>" required>
</label>
<?php if (isset($errores['nombre'])): ?><div class="error"><?= e($errores['nombre']) ?></div><?php endif; ?>
<label>Email *
<input type="email" name="email" value="<?= e($cliente['email']) ?>" required>
</label>
<?php if (isset($errores['email'])): ?><div class="error"><?= e($errores['email']) ?></div><?php endif; ?>
<label>Teléfono
<input name="telefono" value="<?= e($cliente['telefono']) ?>">
</label>
<?php if (isset($errores['telefono'])): ?><div class="error"><?= e($errores['telefono']) ?></div><?php endif; ?>
<label>Empresa
<input name="empresa" value="<?= e($cliente['empresa']) ?>">
</label>
<p style="margin-top:1.5rem">
<button class="btn">Guardar</button>
<a href="index.php">Cancelar</a>
</p>crear.php (Create)
php
<?php
// crear.php
require __DIR__ . '/config.php';
// Valores iniciales vacíos
$cliente = ['nombre' => '', 'email' => '', 'telefono' => '', 'empresa' => ''];
$errores = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verificarCsrf();
$cliente = datosDelFormulario();
$errores = validarCliente($cliente);
if (!$errores) {
$stmt = db()->prepare(
'INSERT INTO clientes (nombre, email, telefono, empresa)
VALUES (:nombre, :email, :telefono, :empresa)'
);
// Guardamos NULL en lugar de cadenas vacías para los campos opcionales
$stmt->execute([
'nombre' => $cliente['nombre'],
'email' => $cliente['email'],
'telefono' => $cliente['telefono'] ?: null,
'empresa' => $cliente['empresa'] ?: null,
]);
flash('ok', 'Cliente creado correctamente.');
redirigir('index.php'); // PRG: redirigir tras un POST exitoso
}
}
$titulo = 'Nuevo cliente';
require __DIR__ . '/_header.php';
?>
<h1>Nuevo cliente</h1>
<form method="post" novalidate>
<?php require __DIR__ . '/_form.php'; ?>
</form>
<?php require __DIR__ . '/_footer.php'; ?>Si hay errores, el formulario se vuelve a mostrar con los datos que el usuario ya escribió (porque $cliente contiene lo enviado) y los mensajes bajo cada campo.
editar.php (Update)
php
<?php
// editar.php
require __DIR__ . '/config.php';
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if (!$id) {
redirigir('index.php');
}
// Buscar el cliente
$stmt = db()->prepare('SELECT * FROM clientes WHERE id = ?');
$stmt->execute([$id]);
$cliente = $stmt->fetch();
if (!$cliente) {
flash('error', 'El cliente no existe.');
redirigir('index.php');
}
$errores = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verificarCsrf();
$cliente = datosDelFormulario();
$errores = validarCliente($cliente, $id); // excluye su propio email de la comprobación
if (!$errores) {
$stmt = db()->prepare(
'UPDATE clientes
SET nombre = :nombre, email = :email, telefono = :telefono, empresa = :empresa
WHERE id = :id'
);
$stmt->execute([
'nombre' => $cliente['nombre'],
'email' => $cliente['email'],
'telefono' => $cliente['telefono'] ?: null,
'empresa' => $cliente['empresa'] ?: null,
'id' => $id,
]);
flash('ok', 'Cliente actualizado.');
redirigir('index.php');
}
}
$titulo = 'Editar cliente';
require __DIR__ . '/_header.php';
?>
<h1>Editar: <?= e($cliente['nombre']) ?></h1>
<form method="post" novalidate>
<?php require __DIR__ . '/_form.php'; ?>
</form>
<?php require __DIR__ . '/_footer.php'; ?>borrar.php (Delete)
php
<?php
// borrar.php
require __DIR__ . '/config.php';
// Sólo aceptamos POST: un enlace GET podría ser activado por un bot o un ataque CSRF
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Método no permitido');
}
verificarCsrf();
$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
if ($id) {
$stmt = db()->prepare('DELETE FROM clientes WHERE id = ?');
$stmt->execute([$id]);
$stmt->rowCount() > 0
? flash('ok', 'Cliente eliminado.')
: flash('error', 'El cliente no existe.');
}
redirigir('index.php');Nunca borres con un enlace GET
borrar.php?id=5 parece cómodo, pero cualquier sitio podría incluir una imagen con esa URL y borrar datos cuando un administrador logueado la visite. Además, algunos navegadores y extensiones "precargan" enlaces. Las acciones que modifican datos van siempre por POST con token CSRF. Ver CSRF.
Buena práctica: borrado lógico
En muchos sistemas es mejor no borrar realmente, sino marcar el registro: añade una columna eliminado_en DATETIME NULL y haz UPDATE clientes SET eliminado_en = NOW() WHERE id = ?. En los listados filtra con WHERE eliminado_en IS NULL. Así puedes recuperar datos borrados por error, algo que los clientes agradecen mucho.
Paso a paso: cómo probarlo
- Crea la carpeta
crud-clientesdentro dehtdocs(XAMPP) owww(Laragon), o en cualquier lugar si usasphp -S localhost:8000. - Ejecuta el SQL en phpMyAdmin.
- Copia los 8 archivos.
- Abre
http://localhost/crud-clientes/(ohttp://localhost:8000). - Prueba: crea un cliente con email repetido, deja el nombre vacío, edita, busca y borra.
Error común
"Headers already sent". Ocurre cuando llamas a header() (dentro de redirigir) después de haber enviado HTML. Por eso en crear.php y editar.php toda la lógica va antes de require '_header.php'. También lo causa un espacio o línea en blanco antes de la etiqueta <?php de apertura.
Ejercicios
- Agrega paginación al listado (10 clientes por página) manteniendo el parámetro de búsqueda en los enlaces.
- Implementa el borrado lógico descrito arriba y una página
papelera.phppara restaurar clientes. - Añade un botón "Exportar CSV" que descargue todos los clientes.
Ver solución
php
<?php
// 1: fragmento para index.php
$porPagina = 10;
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
$offset = ($pagina - 1) * $porPagina;
$where = '';
$params = [];
if ($q !== '') {
$where = 'WHERE nombre LIKE :q OR email LIKE :q OR empresa LIKE :q';
$params['q'] = "%$q%";
}
$stmt = db()->prepare("SELECT COUNT(*) FROM clientes $where");
$stmt->execute($params);
$totalPaginas = (int) ceil($stmt->fetchColumn() / $porPagina);
$stmt = db()->prepare("SELECT * FROM clientes $where ORDER BY nombre LIMIT :lim OFFSET :off");
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
$stmt->bindValue('lim', $porPagina, PDO::PARAM_INT);
$stmt->bindValue('off', $offset, PDO::PARAM_INT);
$stmt->execute();
$clientes = $stmt->fetchAll();
// En la vista, http_build_query conserva la búsqueda:
// <a href="?<?= http_build_query(['q' => $q, 'pagina' => $i]) ?>"><?= $i ?></a>sql
-- 2
ALTER TABLE clientes ADD eliminado_en DATETIME NULL;php
<?php
// 2: borrar.php cambia el DELETE por:
$stmt = db()->prepare('UPDATE clientes SET eliminado_en = NOW() WHERE id = ? AND eliminado_en IS NULL');
// index.php añade "eliminado_en IS NULL" a sus consultas.
// papelera.php
require __DIR__ . '/config.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verificarCsrf();
$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
db()->prepare('UPDATE clientes SET eliminado_en = NULL WHERE id = ?')->execute([$id]);
flash('ok', 'Cliente restaurado.');
redirigir('papelera.php');
}
$borrados = db()->query('SELECT * FROM clientes WHERE eliminado_en IS NOT NULL ORDER BY eliminado_en DESC')->fetchAll();
require __DIR__ . '/_header.php';
?>
<h1>Papelera</h1>
<?php foreach ($borrados as $c): ?>
<form method="post">
<?= csrfCampo() ?>
<input type="hidden" name="id" value="<?= (int) $c['id'] ?>">
<?= e($c['nombre']) ?> (borrado el <?= e($c['eliminado_en']) ?>)
<button class="btn">Restaurar</button>
</form>
<?php endforeach; ?>
<?php require __DIR__ . '/_footer.php'; ?>php
<?php
// 3: exportar.php
require __DIR__ . '/config.php';
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="clientes-' . date('Y-m-d') . '.csv"');
$salida = fopen('php://output', 'w');
fwrite($salida, "\xEF\xBB\xBF"); // BOM para que Excel muestre bien los acentos
fputcsv($salida, ['ID', 'Nombre', 'Email', 'Teléfono', 'Empresa', 'Alta']);
$stmt = db()->query('SELECT id, nombre, email, telefono, empresa, creado_en FROM clientes ORDER BY nombre');
while ($fila = $stmt->fetch(PDO::FETCH_NUM)) {
fputcsv($salida, $fila);
}
fclose($salida);Resumen
- CRUD = crear, leer, actualizar y borrar; cada uno con su consulta y su pantalla.
- Aplica el patrón PRG: procesa el POST, guarda un mensaje flash y redirige.
- Reutiliza el formulario entre crear y editar, y repobla los campos cuando hay errores.
- Escapa toda salida con
htmlspecialcharsy usa prepared statements en todas las consultas. - Borrar es una acción POST con token CSRF; considera el borrado lógico.
- Toda la lógica que pueda redirigir va antes de imprimir HTML.