Skip to content

CRUD completo con PDO ​

Un módulo de productos con listado, alta, edición y borrado. Usa un pequeño "repositorio" para separar las consultas SQL de las páginas, lo que hace el código más fácil de mantener y de reutilizar en otras tablas.

Tabla ​

sql
CREATE TABLE productos (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  nombre VARCHAR(150) NOT NULL,
  descripcion TEXT NULL,
  precio DECIMAL(10,2) NOT NULL DEFAULT 0,
  stock INT NOT NULL DEFAULT 0,
  activo TINYINT(1) NOT NULL DEFAULT 1,
  creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  actualizado_en TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Estructura ​

txt
includes/
  db.php, helpers.php, auth.php
  ProductoRepo.php     ← consultas SQL
productos/
  index.php            ← listar
  form.php             ← crear y editar (mismo formulario)
  eliminar.php         ← borrar (POST)

Repositorio ​

php
<?php
require_once __DIR__ . '/db.php';

final class ProductoRepo
{
    public static function todos(): array
    {
        return db()->query('SELECT * FROM productos ORDER BY id DESC')->fetchAll();
    }

    public static function buscar(int $id): ?array
    {
        $stmt = db()->prepare('SELECT * FROM productos WHERE id = ?');
        $stmt->execute([$id]);
        return $stmt->fetch() ?: null;
    }

    public static function crear(array $d): int
    {
        $stmt = db()->prepare(
            'INSERT INTO productos (nombre, descripcion, precio, stock, activo)
             VALUES (:nombre, :descripcion, :precio, :stock, :activo)'
        );
        $stmt->execute($d);
        return (int) db()->lastInsertId();
    }

    public static function actualizar(int $id, array $d): void
    {
        $d['id'] = $id;
        $stmt = db()->prepare(
            'UPDATE productos
             SET nombre = :nombre, descripcion = :descripcion, precio = :precio,
                 stock = :stock, activo = :activo
             WHERE id = :id'
        );
        $stmt->execute($d);
    }

    public static function eliminar(int $id): void
    {
        db()->prepare('DELETE FROM productos WHERE id = ?')->execute([$id]);
    }

    /** Valida y normaliza los datos del formulario. Devuelve [datos, errores] */
    public static function validar(array $in): array
    {
        $d = [
            'nombre'      => trim($in['nombre'] ?? ''),
            'descripcion' => trim($in['descripcion'] ?? '') ?: null,
            'precio'      => str_replace(',', '.', $in['precio'] ?? '0'),
            'stock'       => $in['stock'] ?? '0',
            'activo'      => isset($in['activo']) ? 1 : 0,
        ];
        $err = [];
        if ($d['nombre'] === '' || mb_strlen($d['nombre']) > 150) $err['nombre'] = 'Nombre obligatorio (máx. 150).';
        if (!is_numeric($d['precio']) || $d['precio'] < 0)       $err['precio'] = 'Precio no válido.';
        if (filter_var($d['stock'], FILTER_VALIDATE_INT) === false) $err['stock'] = 'El stock debe ser un número entero.';
        $d['precio'] = (float) $d['precio'];
        $d['stock']  = (int) $d['stock'];
        return [$d, $err];
    }
}

Listar ​

php
<?php
require __DIR__ . '/../includes/auth.php';
require __DIR__ . '/../includes/ProductoRepo.php';
requiere_login();

$productos = ProductoRepo::todos();
$ok = flash('ok');
$fmt = new NumberFormatter('es_MX', NumberFormatter::CURRENCY);
?>
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title>Productos</title></head>
<body>
  <h1>Productos</h1>
  <?php if ($ok): ?><p class="ok"><?= e($ok) ?></p><?php endif; ?>
  <p><a href="form.php">+ Nuevo producto</a></p>

  <table border="1" cellpadding="6">
    <thead><tr><th>ID</th><th>Nombre</th><th>Precio</th><th>Stock</th><th>Activo</th><th></th></tr></thead>
    <tbody>
    <?php foreach ($productos as $p): ?>
      <tr>
        <td><?= $p['id'] ?></td>
        <td><?= e($p['nombre']) ?></td>
        <td><?= $fmt->formatCurrency((float) $p['precio'], 'MXN') ?></td>
        <td><?= (int) $p['stock'] ?></td>
        <td><?= $p['activo'] ? 'Sí' : 'No' ?></td>
        <td>
          <a href="form.php?id=<?= $p['id'] ?>">Editar</a>
          <form method="post" action="eliminar.php" style="display:inline"
                onsubmit="return confirm('¿Eliminar este producto?')">
            <?= csrf_field() ?>
            <input type="hidden" name="id" value="<?= $p['id'] ?>">
            <button>Eliminar</button>
          </form>
        </td>
      </tr>
    <?php endforeach; ?>
    <?php if (!$productos): ?>
      <tr><td colspan="6">Aún no hay productos.</td></tr>
    <?php endif; ?>
    </tbody>
  </table>
</body>
</html>

NumberFormatter

NumberFormatter requiere la extensión intl. Si tu hosting no la tiene, usa '$' . number_format($p['precio'], 2).

Crear y editar con el mismo formulario ​

php
<?php
require __DIR__ . '/../includes/auth.php';
require __DIR__ . '/../includes/ProductoRepo.php';
requiere_login();

$id = (int) ($_GET['id'] ?? 0);
$producto = $id ? ProductoRepo::buscar($id) : null;

if ($id && !$producto) {
    http_response_code(404);
    exit('Producto no encontrado');
}

// Valores iniciales: los del producto o vacíos
$datos = $producto ?? ['nombre' => '', 'descripcion' => '', 'precio' => '', 'stock' => 0, 'activo' => 1];
$errores = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    csrf_check();
    [$datos, $errores] = ProductoRepo::validar($_POST);

    if (!$errores) {
        if ($id) {
            ProductoRepo::actualizar($id, $datos);
            flash('ok', 'Producto actualizado.');
        } else {
            ProductoRepo::crear($datos);
            flash('ok', 'Producto creado.');
        }
        redirect('index.php'); // Patrón PRG: evita reenvíos al recargar
    }
}
?>
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title><?= $id ? 'Editar' : 'Nuevo' ?> producto</title></head>
<body>
  <h1><?= $id ? 'Editar' : 'Nuevo' ?> producto</h1>
  <form method="post">
    <?= csrf_field() ?>
    <p>
      <label>Nombre<br><input name="nombre" value="<?= e($datos['nombre']) ?>" required maxlength="150"></label>
      <small><?= $errores['nombre'] ?? '' ?></small>
    </p>
    <p>
      <label>Descripción<br><textarea name="descripcion" rows="4"><?= e($datos['descripcion']) ?></textarea></label>
    </p>
    <p>
      <label>Precio<br><input name="precio" type="number" step="0.01" min="0" value="<?= e((string) $datos['precio']) ?>"></label>
      <small><?= $errores['precio'] ?? '' ?></small>
    </p>
    <p>
      <label>Stock<br><input name="stock" type="number" step="1" value="<?= (int) $datos['stock'] ?>"></label>
      <small><?= $errores['stock'] ?? '' ?></small>
    </p>
    <p><label><input type="checkbox" name="activo" <?= $datos['activo'] ? 'checked' : '' ?>> Activo</label></p>
    <button>Guardar</button> <a href="index.php">Cancelar</a>
  </form>
</body>
</html>

Eliminar ​

php
<?php
require __DIR__ . '/../includes/auth.php';
require __DIR__ . '/../includes/ProductoRepo.php';
requiere_login();

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    exit('Método no permitido');
}
csrf_check();

try {
    ProductoRepo::eliminar((int) ($_POST['id'] ?? 0));
    flash('ok', 'Producto eliminado.');
} catch (PDOException $e) {
    // 23000 = violación de llave foránea (p. ej. el producto ya está en pedidos)
    flash('ok', $e->getCode() === '23000'
        ? 'No se puede eliminar: el producto tiene pedidos. Desactívalo en su lugar.'
        : 'No se pudo eliminar.');
}
redirect('index.php');

Cómo adaptarlo a otra tabla ​

  1. Copia ProductoRepo.php como ClienteRepo.php.
  2. Cambia el nombre de la tabla y las columnas en cada consulta.
  3. Ajusta validar() a los campos nuevos.
  4. Duplica la carpeta productos/ y cambia los campos del formulario.

Error común

Borrar con un enlace GET (eliminar.php?id=5). Cualquier bot, extensión o vista previa de enlaces puede disparar el borrado. Usa siempre POST + CSRF para acciones que modifican datos.

Borrado lógico

En proyectos de clientes muchas veces conviene no borrar de verdad: agrega una columna eliminado_en DATETIME NULL y filtra con WHERE eliminado_en IS NULL. Así puedes recuperar datos borrados por error.

Academia Web — aprende, copia y construye.