Skip to content

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) y empleado (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 subidas

1. 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 -Indexes

9. Probar en local ​

bash
mkdir logs
mkdir -p public_html/uploads/productos
php crear-admin.php
php -S localhost:8000 -t public_html

Prueba: 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) ​

  1. 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).
  2. Importar tablas: abre phpMyAdmin, selecciona la base > pestaña "Importar" > sube database.sql.
  3. Versión de PHP: en "Seleccionar versión de PHP" (o "MultiPHP Manager") elige 8.3 o superior y activa las extensiones pdo_mysql, fileinfo e intl.
  4. 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 de public_html/ del proyecto va en el public_html del hosting; app/, logs/ y .env quedan en /home/micuenta/inventario/. Ajusta las rutas require __DIR__ . '/../app/...' si la ubicación cambia (por ejemplo a /../inventario/app/).
  5. .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
  1. Permisos: carpetas 755, archivos 644, .env en 600 si el hosting lo permite. logs/ y uploads/productos/ deben ser escribibles (normalmente con 755 basta en cPanel).
  2. 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"
  1. Primer usuario: si no hay terminal, genera el hash en tu PC y haz el INSERT desde phpMyAdmin. Borra crear-admin.php del servidor.
  2. Verifica: abre el sitio, intenta acceder a https://midominio.com/.env (debe dar 403 o 404), revisa que los errores no se muestren y que logs/app.log registre 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) ​

  1. Agrega un módulo de usuarios (sólo admin): listar, crear con contraseña temporal, desactivar.
  2. Registra movimientos de inventario (entradas y salidas) en una tabla propia, actualizando el stock dentro de una transacción.
  3. 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, .env y 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, .env de producción, HTTPS y .htaccess protector.
  • Ya tienes la base para entender frameworks: continúa con el curso de Laravel.

Academia Web — aprende, copia y construye.