Tema
Proyecto final: sistema con panel
Es hora de juntar todo lo aprendido en un sistema real, del tipo que un pequeño negocio te pagaría: un sistema de inventario con inicio de sesión, roles, CRUD de productos, panel con estadísticas y listo para subir a un hosting compartido con cPanel.
Qué vas a construir
- Login seguro con
password_hash, regeneración de sesión y límite de intentos. - Roles:
admin(todo) yempleado(ver y editar, pero no borrar). - Panel con totales: productos, unidades, valor del inventario y productos con poco stock.
- CRUD de productos con búsqueda, validación, imagen opcional, CSRF y mensajes flash.
- Configuración por
.env, errores registrados en un log y estructura segura para producción.
Estructura de carpetas
La clave de seguridad: sólo public_html/ es accesible desde Internet. El código, la configuración y los logs quedan fuera.
txt
inventario/
├── app/
│ ├── bootstrap.php ← se incluye al inicio de cada página
│ ├── helpers.php ← e(), redirigir(), flash, csrf
│ ├── auth.php ← login, logout, requerirLogin, requerirRol
│ └── vistas/
│ ├── header.php
│ └── footer.php
├── logs/ ← app.log (permisos de escritura)
├── database.sql
├── crear-admin.php ← script de terminal para el primer usuario
├── .env
└── public_html/ ← raíz pública del dominio
├── .htaccess
├── index.php ← redirige al panel
├── login.php
├── logout.php
├── panel.php
├── productos.php ← listado + búsqueda
├── producto-form.php ← crear y editar
├── producto-borrar.php
├── assets/app.css
└── uploads/productos/ ← imágenes subidas1. Base de datos
sql
-- database.sql
CREATE TABLE usuarios (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
rol ENUM('admin', 'empleado') NOT NULL DEFAULT 'empleado',
activo TINYINT(1) NOT NULL DEFAULT 1,
ultimo_login DATETIME NULL,
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE productos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
sku VARCHAR(30) NOT NULL UNIQUE,
nombre VARCHAR(150) NOT NULL,
precio DECIMAL(10,2) NOT NULL,
stock INT UNSIGNED NOT NULL DEFAULT 0,
stock_minimo INT UNSIGNED NOT NULL DEFAULT 5,
imagen VARCHAR(60) NULL,
creado_por INT UNSIGNED NULL,
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
actualizado_en DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (creado_por) REFERENCES usuarios(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE intentos_login (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
ip VARCHAR(45) NOT NULL,
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX (ip, creado_en)
) ENGINE=InnoDB;env
# .env
APP_ENV=local
APP_URL=http://localhost:8000
DB_HOST=127.0.0.1
DB_NAME=inventario
DB_USER=root
DB_PASS=2. Arranque de la aplicación
Para no depender de Composer en el hosting, leemos el .env con una función propia.
php
<?php
// app/bootstrap.php
declare(strict_types=1);
define('RAIZ', dirname(__DIR__));
// --- Cargar .env (formato CLAVE=valor) ---
foreach (file(RAIZ . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $linea) {
if (str_starts_with(trim($linea), '#') || !str_contains($linea, '=')) continue;
[$clave, $valor] = array_map('trim', explode('=', $linea, 2));
$_ENV[$clave] = trim($valor, "\"'");
}
$produccion = ($_ENV['APP_ENV'] ?? 'production') === 'production';
// --- Errores: visibles en local, sólo en log en producción ---
error_reporting(E_ALL);
ini_set('display_errors', $produccion ? '0' : '1');
ini_set('log_errors', '1');
ini_set('error_log', RAIZ . '/logs/app.log');
date_default_timezone_set('America/Mexico_City');
set_exception_handler(function (Throwable $e) use ($produccion): void {
error_log((string) $e);
http_response_code(500);
echo $produccion ? 'Ocurrió un error. Intenta más tarde.' : '<pre>' . htmlspecialchars((string) $e) . '</pre>';
});
// --- Sesión segura ---
session_set_cookie_params([
'lifetime' => 0, // hasta cerrar el navegador
'path' => '/',
'secure' => $produccion, // sólo por HTTPS en producción
'httponly' => true, // JavaScript no puede leer la cookie
'samesite' => 'Lax',
]);
session_name('inventario_sid');
session_start();
// Cabeceras de seguridad básicas
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
require __DIR__ . '/helpers.php';
require __DIR__ . '/auth.php';3. Funciones de ayuda
php
<?php
// app/helpers.php
declare(strict_types=1);
function db(): PDO
{
static $pdo = null;
return $pdo ??= new PDO(
"mysql:host={$_ENV['DB_HOST']};dbname={$_ENV['DB_NAME']};charset=utf8mb4",
$_ENV['DB_USER'],
$_ENV['DB_PASS'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
}
function e(mixed $v): string
{
return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
}
function redirigir(string $url): never
{
header("Location: $url");
exit;
}
function flash(?string $mensaje = null, string $tipo = 'ok'): ?array
{
if ($mensaje !== null) { // guardar
$_SESSION['flash'] = compact('mensaje', 'tipo');
return null;
}
$f = $_SESSION['flash'] ?? null; // leer y borrar
unset($_SESSION['flash']);
return $f;
}
function csrf_campo(): string
{
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
return '<input type="hidden" name="csrf" value="' . $_SESSION['csrf'] . '">';
}
function verificar_csrf(): void
{
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(419);
exit('La sesión expiró. Vuelve atrás y recarga la página.');
}
}
function dinero(float|string $n): string
{
return '$' . number_format((float) $n, 2);
}4. Autenticación y roles
php
<?php
// app/auth.php
declare(strict_types=1);
const MAX_INTENTOS = 5; // intentos fallidos permitidos...
const VENTANA_MINUTOS = 15; // ...en esta ventana de tiempo
function usuario(): ?array
{
return $_SESSION['usuario'] ?? null;
}
function demasiadosIntentos(string $ip): bool
{
$stmt = db()->prepare(
'SELECT COUNT(*) FROM intentos_login
WHERE ip = ? AND creado_en > (NOW() - INTERVAL ' . VENTANA_MINUTOS . ' MINUTE)'
);
$stmt->execute([$ip]);
return (int) $stmt->fetchColumn() >= MAX_INTENTOS;
}
/**
* Intenta iniciar sesión. Devuelve null si tuvo éxito o un mensaje de error.
*/
function intentarLogin(string $email, string $password): ?string
{
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if (demasiadosIntentos($ip)) {
return 'Demasiados intentos. Espera ' . VENTANA_MINUTOS . ' minutos.';
}
$stmt = db()->prepare('SELECT * FROM usuarios WHERE email = ? AND activo = 1');
$stmt->execute([mb_strtolower(trim($email))]);
$u = $stmt->fetch();
// password_verify compara contra el hash de forma segura
if (!$u || !password_verify($password, $u['password_hash'])) {
db()->prepare('INSERT INTO intentos_login (ip) VALUES (?)')->execute([$ip]);
return 'Email o contraseña incorrectos.'; // mensaje genérico: no revela cuál falló
}
// Si PHP mejoró su algoritmo por defecto, actualizamos el hash
if (password_needs_rehash($u['password_hash'], PASSWORD_DEFAULT)) {
db()->prepare('UPDATE usuarios SET password_hash = ? WHERE id = ?')
->execute([password_hash($password, PASSWORD_DEFAULT), $u['id']]);
}
// Nuevo ID de sesión: evita la fijación de sesión
session_regenerate_id(true);
$_SESSION['usuario'] = ['id' => (int) $u['id'], 'nombre' => $u['nombre'], 'rol' => $u['rol']];
$_SESSION['ultima_actividad'] = time();
db()->prepare('DELETE FROM intentos_login WHERE ip = ?')->execute([$ip]);
db()->prepare('UPDATE usuarios SET ultimo_login = NOW() WHERE id = ?')->execute([$u['id']]);
return null;
}
function cerrarSesion(): void
{
$_SESSION = [];
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 3600, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
session_destroy();
}
function requerirLogin(): array
{
$u = usuario();
// Cierre automático tras 30 minutos sin actividad
if ($u && time() - ($_SESSION['ultima_actividad'] ?? 0) > 1800) {
cerrarSesion();
$u = null;
}
if (!$u) {
redirigir('login.php');
}
$_SESSION['ultima_actividad'] = time();
return $u;
}
function requerirRol(string $rol): array
{
$u = requerirLogin();
if ($u['rol'] !== $rol) {
http_response_code(403);
exit('No tienes permiso para esta acción.');
}
return $u;
}Crear el primer administrador
php
<?php
// crear-admin.php — ejecútalo UNA vez: php crear-admin.php
require __DIR__ . '/app/bootstrap.php';
if (PHP_SAPI !== 'cli') exit('Sólo desde la terminal.');
$nombre = readline('Nombre: ');
$email = mb_strtolower(trim(readline('Email: ')));
$pass = readline('Contraseña (mín. 10 caracteres): ');
if (mb_strlen($pass) < 10) exit("Contraseña demasiado corta.\n");
db()->prepare('INSERT INTO usuarios (nombre, email, password_hash, rol) VALUES (?, ?, ?, ?)')
->execute([$nombre, $email, password_hash($pass, PASSWORD_DEFAULT), 'admin']);
echo "Administrador creado.\n";En hosting sin terminal, genera el hash en tu PC con php -r "echo password_hash('TuClave', PASSWORD_DEFAULT);" e inserta el usuario desde phpMyAdmin.
5. Vistas compartidas
php
<?php // app/vistas/header.php
$u = usuario();
$f = flash();
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($titulo ?? 'Inventario') ?></title>
<link rel="stylesheet" href="assets/app.css">
</head>
<body>
<?php if ($u): ?>
<header class="barra">
<strong>Inventario</strong>
<nav>
<a href="panel.php">Panel</a>
<a href="productos.php">Productos</a>
</nav>
<span><?= e($u['nombre']) ?> (<?= e($u['rol']) ?>) · <a href="logout.php">Salir</a></span>
</header>
<?php endif; ?>
<main class="contenedor">
<?php if ($f): ?><div class="flash <?= e($f['tipo']) ?>"><?= e($f['mensaje']) ?></div><?php endif; ?>php
<?php // app/vistas/footer.php ?>
</main>
</body>
</html>css
/* public_html/assets/app.css */
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; background: #f4f6f9; color: #1f2937; }
.barra { display: flex; gap: 1.5rem; align-items: center; justify-content: space-between; flex-wrap: wrap; padding: .8rem 1.2rem; background: #111827; color: #fff; }
.barra a { color: #93c5fd; margin-right: 1rem; }
.contenedor { max-width: 1000px; margin: 1.5rem auto; padding: 0 1rem; }
.tarjetas { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }
.tarjeta { background: #fff; padding: 1.2rem; border-radius: 10px; box-shadow: 0 1px 3px #0001; }
.tarjeta b { display: block; font-size: 1.8rem; }
table { width: 100%; border-collapse: collapse; background: #fff; }
th, td { padding: .6rem; border-bottom: 1px solid #e5e7eb; text-align: left; }
.btn { padding: .45rem .9rem; border: 0; border-radius: 6px; background: #2563eb; color: #fff; text-decoration: none; cursor: pointer; }
.btn.rojo { background: #dc2626; }
.flash { padding: .8rem; border-radius: 6px; margin-bottom: 1rem; background: #dcfce7; }
.flash.error { background: #fee2e2; }
.error { color: #dc2626; font-size: .85rem; }
input { width: 100%; padding: .55rem; margin: .3rem 0 .8rem; border: 1px solid #d1d5db; border-radius: 6px; }
.login { max-width: 360px; margin: 10vh auto; }
.bajo { color: #dc2626; font-weight: bold; }6. Login, logout e inicio
php
<?php
// public_html/login.php
require __DIR__ . '/../app/bootstrap.php';
if (usuario()) redirigir('panel.php');
$error = null;
$email = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verificar_csrf();
$email = $_POST['email'] ?? '';
$error = intentarLogin($email, $_POST['password'] ?? '');
if ($error === null) {
redirigir('panel.php');
}
}
$titulo = 'Iniciar sesión';
require __DIR__ . '/../app/vistas/header.php';
?>
<div class="login tarjeta">
<h1>Iniciar sesión</h1>
<?php if ($error): ?><p class="error"><?= e($error) ?></p><?php endif; ?>
<form method="post">
<?= csrf_campo() ?>
<label>Email <input type="email" name="email" value="<?= e($email) ?>" required autofocus></label>
<label>Contraseña <input type="password" name="password" required></label>
<button class="btn">Entrar</button>
</form>
</div>
<?php require __DIR__ . '/../app/vistas/footer.php'; ?>php
<?php
// public_html/logout.php
require __DIR__ . '/../app/bootstrap.php';
cerrarSesion();
redirigir('login.php');php
<?php
// public_html/index.php
require __DIR__ . '/../app/bootstrap.php';
redirigir(usuario() ? 'panel.php' : 'login.php');7. Panel con estadísticas
php
<?php
// public_html/panel.php
require __DIR__ . '/../app/bootstrap.php';
$u = requerirLogin();
// Una sola consulta para todos los totales
$stats = db()->query(
'SELECT COUNT(*) AS productos,
COALESCE(SUM(stock), 0) AS unidades,
COALESCE(SUM(stock * precio), 0) AS valor,
SUM(stock <= stock_minimo) AS bajos
FROM productos'
)->fetch();
$bajos = db()->query(
'SELECT id, nombre, stock, stock_minimo FROM productos
WHERE stock <= stock_minimo ORDER BY stock LIMIT 10'
)->fetchAll();
$titulo = 'Panel';
require __DIR__ . '/../app/vistas/header.php';
?>
<h1>Hola, <?= e($u['nombre']) ?></h1>
<div class="tarjetas">
<div class="tarjeta">Productos <b><?= (int) $stats['productos'] ?></b></div>
<div class="tarjeta">Unidades en stock <b><?= (int) $stats['unidades'] ?></b></div>
<div class="tarjeta">Valor del inventario <b><?= dinero($stats['valor']) ?></b></div>
<div class="tarjeta">Con poco stock <b class="bajo"><?= (int) $stats['bajos'] ?></b></div>
</div>
<h2>Reabastecer pronto</h2>
<?php if (!$bajos): ?>
<p>Todo el inventario está en niveles correctos.</p>
<?php else: ?>
<table>
<tr><th>Producto</th><th>Stock</th><th>Mínimo</th><th></th></tr>
<?php foreach ($bajos as $p): ?>
<tr>
<td><?= e($p['nombre']) ?></td>
<td class="bajo"><?= (int) $p['stock'] ?></td>
<td><?= (int) $p['stock_minimo'] ?></td>
<td><a href="producto-form.php?id=<?= (int) $p['id'] ?>">Actualizar</a></td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
<?php require __DIR__ . '/../app/vistas/footer.php'; ?>8. CRUD de productos
php
<?php
// public_html/productos.php
require __DIR__ . '/../app/bootstrap.php';
$u = requerirLogin();
$q = trim($_GET['q'] ?? '');
$stmt = db()->prepare('SELECT * FROM productos WHERE nombre LIKE :q OR sku LIKE :q ORDER BY nombre');
$stmt->execute(['q' => "%$q%"]);
$productos = $stmt->fetchAll();
$titulo = 'Productos';
require __DIR__ . '/../app/vistas/header.php';
?>
<h1>Productos</h1>
<p><a class="btn" href="producto-form.php">+ Nuevo</a></p>
<form><input type="search" name="q" value="<?= e($q) ?>" placeholder="Buscar por nombre o SKU"></form>
<table>
<tr><th></th><th>SKU</th><th>Nombre</th><th>Precio</th><th>Stock</th><th></th></tr>
<?php foreach ($productos as $p): ?>
<tr>
<td><?php if ($p['imagen']): ?><img src="uploads/productos/<?= e($p['imagen']) ?>" width="40" alt=""><?php endif; ?></td>
<td><?= e($p['sku']) ?></td>
<td><?= e($p['nombre']) ?></td>
<td><?= dinero($p['precio']) ?></td>
<td class="<?= $p['stock'] <= $p['stock_minimo'] ? 'bajo' : '' ?>"><?= (int) $p['stock'] ?></td>
<td>
<a href="producto-form.php?id=<?= (int) $p['id'] ?>">Editar</a>
<?php if ($u['rol'] === 'admin'): ?>
<form method="post" action="producto-borrar.php" style="display:inline" onsubmit="return confirm('¿Eliminar este producto?')">
<?= csrf_campo() ?>
<input type="hidden" name="id" value="<?= (int) $p['id'] ?>">
<button class="btn rojo">Borrar</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php require __DIR__ . '/../app/vistas/footer.php'; ?>php
<?php
// public_html/producto-form.php — crear (sin id) y editar (?id=N)
require __DIR__ . '/../app/bootstrap.php';
$u = requerirLogin();
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT) ?: null;
$p = ['sku' => '', 'nombre' => '', 'precio' => '', 'stock' => 0, 'stock_minimo' => 5, 'imagen' => null];
if ($id) {
$stmt = db()->prepare('SELECT * FROM productos WHERE id = ?');
$stmt->execute([$id]);
$p = $stmt->fetch() ?: redirigir('productos.php');
}
$errores = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verificar_csrf();
$p = array_merge($p, [
'sku' => strtoupper(trim($_POST['sku'] ?? '')),
'nombre' => trim($_POST['nombre'] ?? ''),
'precio' => $_POST['precio'] ?? '',
'stock' => $_POST['stock'] ?? '',
'stock_minimo' => $_POST['stock_minimo'] ?? '',
]);
// --- Validación ---
if (!preg_match('/^[A-Z0-9-]{3,30}$/', $p['sku'])) $errores['sku'] = 'SKU de 3 a 30 caracteres (letras, números, guiones).';
if (mb_strlen($p['nombre']) < 3) $errores['nombre'] = 'Nombre demasiado corto.';
if (!is_numeric($p['precio']) || $p['precio'] <= 0) $errores['precio'] = 'Precio inválido.';
foreach (['stock', 'stock_minimo'] as $campo) {
if (filter_var($p[$campo], FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]) === false) {
$errores[$campo] = 'Debe ser un entero mayor o igual a 0.';
}
}
$stmt = db()->prepare('SELECT id FROM productos WHERE sku = ? AND id <> ?');
$stmt->execute([$p['sku'], $id ?? 0]);
if ($stmt->fetch()) $errores['sku'] = 'Ese SKU ya existe.';
// --- Imagen opcional ---
$img = $_FILES['imagen'] ?? null;
if ($img && $img['error'] !== UPLOAD_ERR_NO_FILE) {
$tipos = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
$mime = $img['error'] === UPLOAD_ERR_OK ? (new finfo(FILEINFO_MIME_TYPE))->file($img['tmp_name']) : '';
if (!isset($tipos[$mime]) || $img['size'] > 2_000_000) {
$errores['imagen'] = 'Imagen JPG, PNG o WEBP de máximo 2 MB.';
} elseif (!$errores) {
$nombreImg = bin2hex(random_bytes(16)) . '.' . $tipos[$mime];
move_uploaded_file($img['tmp_name'], __DIR__ . "/uploads/productos/$nombreImg");
if ($p['imagen']) @unlink(__DIR__ . '/uploads/productos/' . basename($p['imagen']));
$p['imagen'] = $nombreImg;
}
}
// --- Guardar ---
if (!$errores) {
$datos = [
'sku' => $p['sku'], 'nombre' => $p['nombre'], 'precio' => $p['precio'],
'stock' => (int) $p['stock'], 'stock_minimo' => (int) $p['stock_minimo'], 'imagen' => $p['imagen'],
];
if ($id) {
db()->prepare('UPDATE productos SET sku=:sku, nombre=:nombre, precio=:precio, stock=:stock,
stock_minimo=:stock_minimo, imagen=:imagen WHERE id=:id')
->execute($datos + ['id' => $id]);
flash('Producto actualizado.');
} else {
db()->prepare('INSERT INTO productos (sku, nombre, precio, stock, stock_minimo, imagen, creado_por)
VALUES (:sku, :nombre, :precio, :stock, :stock_minimo, :imagen, :creado_por)')
->execute($datos + ['creado_por' => $u['id']]);
flash('Producto creado.');
}
redirigir('productos.php');
}
}
$titulo = $id ? 'Editar producto' : 'Nuevo producto';
require __DIR__ . '/../app/vistas/header.php';
// Pequeña función local para pintar un campo con su error
$campo = function (string $nombre, string $etiqueta, string $tipo = 'text') use ($p, $errores): string {
$extra = $tipo === 'number' ? ' step="any" min="0"' : '';
return "<label>$etiqueta<input type=\"$tipo\" name=\"$nombre\" value=\"" . e($p[$nombre]) . "\"$extra></label>"
. (isset($errores[$nombre]) ? '<div class="error">' . e($errores[$nombre]) . '</div>' : '');
};
?>
<h1><?= e($titulo) ?></h1>
<form method="post" enctype="multipart/form-data" class="tarjeta">
<?= csrf_campo() ?>
<?= $campo('sku', 'SKU') ?>
<?= $campo('nombre', 'Nombre') ?>
<?= $campo('precio', 'Precio', 'number') ?>
<?= $campo('stock', 'Stock', 'number') ?>
<?= $campo('stock_minimo', 'Stock mínimo', 'number') ?>
<label>Imagen <input type="file" name="imagen" accept="image/*"></label>
<?php if (isset($errores['imagen'])): ?><div class="error"><?= e($errores['imagen']) ?></div><?php endif; ?>
<button class="btn">Guardar</button> <a href="productos.php">Cancelar</a>
</form>
<?php require __DIR__ . '/../app/vistas/footer.php'; ?>php
<?php
// public_html/producto-borrar.php
require __DIR__ . '/../app/bootstrap.php';
requerirRol('admin'); // sólo administradores
if ($_SERVER['REQUEST_METHOD'] !== 'POST') redirigir('productos.php');
verificar_csrf();
$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
$stmt = db()->prepare('SELECT imagen FROM productos WHERE id = ?');
$stmt->execute([$id]);
if ($fila = $stmt->fetch()) {
db()->prepare('DELETE FROM productos WHERE id = ?')->execute([$id]);
if ($fila['imagen']) @unlink(__DIR__ . '/uploads/productos/' . basename($fila['imagen']));
flash('Producto eliminado.');
}
redirigir('productos.php');apache
# public_html/uploads/.htaccess — nunca ejecutar PHP en la carpeta de subidas
<FilesMatch "\.(php|phtml|phar)$">
Require all denied
</FilesMatch>
Options -Indexes9. Probar en local
bash
mkdir logs
mkdir -p public_html/uploads/productos
php crear-admin.php
php -S localhost:8000 -t public_htmlPrueba: login con contraseña incorrecta 6 veces, crea productos, sube imágenes, entra como empleado (crea uno desde phpMyAdmin) y comprueba que no ve el botón Borrar.
10. Subir a hosting compartido (cPanel)
- Base de datos: en cPanel > "Bases de datos MySQL" crea la base, un usuario con contraseña fuerte y asígnalo con "Todos los privilegios" sobre esa base. Los nombres llevan prefijo (
micuenta_inventario). - Importar tablas: abre phpMyAdmin, selecciona la base > pestaña "Importar" > sube
database.sql. - Versión de PHP: en "Seleccionar versión de PHP" (o "MultiPHP Manager") elige 8.3 o superior y activa las extensiones
pdo_mysql,fileinfoeintl. - Archivos: comprime el proyecto en ZIP, súbelo con el "Administrador de archivos" a la carpeta de tu cuenta (
/home/micuenta/) y extrae. El contenido depublic_html/del proyecto va en elpublic_htmldel hosting;app/,logs/y.envquedan en/home/micuenta/inventario/. Ajusta las rutasrequire __DIR__ . '/../app/...'si la ubicación cambia (por ejemplo a/../inventario/app/). - .env de producción:
env
APP_ENV=production
APP_URL=https://midominio.com
DB_HOST=localhost
DB_NAME=micuenta_inventario
DB_USER=micuenta_invuser
DB_PASS=una-contraseña-larga-y-aleatoria- Permisos: carpetas
755, archivos644,.enven600si el hosting lo permite.logs/yuploads/productos/deben ser escribibles (normalmente con755basta en cPanel). - HTTPS: en "SSL/TLS Status" activa AutoSSL (Let's Encrypt gratuito) y fuerza HTTPS:
apache
# public_html/.htaccess
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Sin listado de carpetas
Options -Indexes
# Proteger archivos sensibles si por error quedan en la carpeta pública
<FilesMatch "^(\.env|.*\.sql|.*\.log|composer\.(json|lock))$">
Require all denied
</FilesMatch>
# Cabecera HSTS (sólo cuando HTTPS ya funciona bien)
Header always set Strict-Transport-Security "max-age=31536000"- Primer usuario: si no hay terminal, genera el hash en tu PC y haz el
INSERTdesde phpMyAdmin. Borracrear-admin.phpdel servidor. - Verifica: abre el sitio, intenta acceder a
https://midominio.com/.env(debe dar 403 o 404), revisa que los errores no se muestren y quelogs/app.logregistre los fallos.
Buena práctica: antes de entregar al cliente
- Programa respaldos de la base de datos (cPanel > "Copias de seguridad" o un cron con
mysqldump). - Entrega un documento corto con: URL, cómo crear usuarios, cómo restablecer contraseñas y tus datos de soporte.
- Repasa la checklist de seguridad.
Error común
Subir el sitio con APP_ENV=local. Los errores se mostrarían en pantalla con rutas y detalles de la base de datos. Revisa el .env de producción dos veces.
Ejercicios (retos de ampliación)
- Agrega un módulo de usuarios (sólo admin): listar, crear con contraseña temporal, desactivar.
- Registra movimientos de inventario (entradas y salidas) en una tabla propia, actualizando el stock dentro de una transacción.
- Agrega "Olvidé mi contraseña" con un token de un solo uso enviado por correo con PHPMailer.
Ver solución (pistas y código clave)
sql
-- 2: tabla de movimientos
CREATE TABLE movimientos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
producto_id INT UNSIGNED NOT NULL,
usuario_id INT UNSIGNED NULL,
tipo ENUM('entrada', 'salida') NOT NULL,
cantidad INT UNSIGNED NOT NULL,
nota VARCHAR(255) NULL,
creado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (producto_id) REFERENCES productos(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- 3: tokens de recuperación
CREATE TABLE password_resets (
usuario_id INT UNSIGNED PRIMARY KEY,
token_hash CHAR(64) NOT NULL,
expira_en DATETIME NOT NULL
) ENGINE=InnoDB;php
<?php
// 2: registrar un movimiento
function registrarMovimiento(int $productoId, string $tipo, int $cantidad, int $usuarioId): void
{
$pdo = db();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT stock FROM productos WHERE id = ? FOR UPDATE');
$stmt->execute([$productoId]);
$stock = $stmt->fetchColumn();
if ($stock === false) throw new RuntimeException('Producto inexistente.');
if ($tipo === 'salida' && $stock < $cantidad) throw new RuntimeException('Stock insuficiente.');
$signo = $tipo === 'entrada' ? '+' : '-';
$pdo->prepare("UPDATE productos SET stock = stock $signo ? WHERE id = ?")->execute([$cantidad, $productoId]);
$pdo->prepare('INSERT INTO movimientos (producto_id, usuario_id, tipo, cantidad) VALUES (?, ?, ?, ?)')
->execute([$productoId, $usuarioId, $tipo, $cantidad]);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
// 3: generar y verificar el token
$token = bin2hex(random_bytes(32));
db()->prepare('REPLACE INTO password_resets (usuario_id, token_hash, expira_en)
VALUES (?, ?, NOW() + INTERVAL 1 HOUR)')
->execute([$usuarioId, hash('sha256', $token)]);
$enlace = $_ENV['APP_URL'] . '/restablecer.php?token=' . $token;
// ... enviar $enlace con PHPMailer (lección 21)
// En restablecer.php:
$stmt = db()->prepare('SELECT usuario_id FROM password_resets WHERE token_hash = ? AND expira_en > NOW()');
$stmt->execute([hash('sha256', $_GET['token'] ?? '')]);
$usuarioId = $stmt->fetchColumn() ?: exit('Enlace inválido o expirado.');
// Tras guardar la nueva contraseña con password_hash: borrar el token
// DELETE FROM password_resets WHERE usuario_id = ?Para el módulo de usuarios (1), reutiliza el patrón de producto-form.php, protege todas las páginas con requerirRol('admin') y nunca muestres ni edites el hash: sólo permite "establecer nueva contraseña".
Resumen
- Estructura segura: sólo
public_html/es pública; código,.envy logs fuera. - Login con
password_hash/password_verify,session_regenerate_id, límite de intentos y cierre por inactividad. - Roles verificados en el servidor en cada acción, no sólo ocultando botones.
- Todo formulario con CSRF, toda consulta preparada, toda salida escapada.
- Despliegue en cPanel: base de datos + phpMyAdmin, PHP 8.3,
.envde producción, HTTPS y.htaccessprotector. - Ya tienes la base para entender frameworks: continúa con el curso de Laravel.