Tema
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
- Copia
ProductoRepo.phpcomoClienteRepo.php. - Cambia el nombre de la tabla y las columnas en cada consulta.
- Ajusta
validar()a los campos nuevos. - 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.