Skip to content

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ónSQLPantallaMétodo HTTP
Listar (Read)SELECTindex.phpGET
Crear (Create)INSERTcrear.phpGET (form) + POST (guardar)
Editar (Update)UPDATEeditar.php?id=5GET (form) + POST (guardar)
Borrar (Delete)DELETEborrar.phpPOST (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.php

Base 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 ​

  1. Crea la carpeta crud-clientes dentro de htdocs (XAMPP) o www (Laragon), o en cualquier lugar si usas php -S localhost:8000.
  2. Ejecuta el SQL en phpMyAdmin.
  3. Copia los 8 archivos.
  4. Abre http://localhost/crud-clientes/ (o http://localhost:8000).
  5. 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 ​

  1. Agrega paginación al listado (10 clientes por página) manteniendo el parámetro de búsqueda en los enlaces.
  2. Implementa el borrado lógico descrito arriba y una página papelera.php para restaurar clientes.
  3. 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 htmlspecialchars y 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.

Academia Web — aprende, copia y construye.