Ir al contenido principal

Lección 3 · Formularios y sesiones

El ida y vuelta

Hasta acá el sistema mostraba datos escritos a mano en el archivo. Para que sirva de algo tiene que recibir datos de alguien: un producto nuevo, una venta, una contraseña. Eso es un formulario, y es la primera vez en el curso en que el programa recibe algo que no escribió el programador.

El mecanismo es siempre el mismo, y conviene tenerlo claro antes de escribir una línea:

  1. El servidor entrega una página con un formulario. Es una respuesta HTTP común, con etiquetas <form> adentro.
  2. El usuario completa los campos y aprieta el botón.
  3. El navegador arma una nueva petición con esos datos y la envía a la dirección que dice el formulario.
  4. El servidor ejecuta el programa de esa dirección, que lee los datos, decide qué hacer y responde —normalmente con otra página.

De ahí sale la primera consecuencia práctica: el programa que procesa no puede confiar en que el formulario se completó bien. Puede llegar vacío, con números donde van letras, con un campo de más o con un texto armado a mano con cualquier cosa. Todo lo que llega se valida en el servidor, siempre, aunque la página tenga validación en el navegador.

GET y POST

Un formulario puede enviar los datos de dos maneras, y elegir bien no es un detalle de estilo: cambia dónde quedan los datos y qué puede hacer un tercero con ellos.

GET POST
Dónde van los datos En la dirección, después del ?. En el cuerpo de la petición.
Se ven Sí: quedan en el historial, en los registros del servidor y en cualquier enlace que se copie. No, salvo que alguien espíe la conexión sin HTTPS.
Tamaño Limitado: unos 2000 caracteres según el servidor. Amplio, configurable.
Se puede guardar en favoritos Sí, y el resultado se puede repetir. No: al recargar, el navegador avisa que va a reenviar los datos.
Para qué sirve Pedir algo: una búsqueda, un filtro, una página del listado. Modificar algo: un alta, una baja, un cambio, un login.

La regla que resume la tabla: GET para leer, POST para escribir. Un buscador es GET porque buscar no cambia nada y conviene que la dirección se pueda compartir. El alta de un producto es POST porque crea algo, y ese algo no tiene que poder crearse con solo abrir un enlace.

Por qué importa más de lo que parece

Si una operación que modifica se hace por GET, cualquiera puede provocarla con un enlace o con una imagen escondida en otra página. El usuario entra a un sitio cualquiera y su navegador pide, sin que se dé cuenta, http://localhost/tienda/borrar.php?codigo=1042. El servidor recibe una petición GET legítima, con la cookie de sesión del usuario, y borra el producto.

Ese ataque se llama CSRF —cross-site request forgery— y el GET lo hace trivial. Con POST hace falta un formulario que se envíe solo, y para eso está el token que se ve más adelante en esta lección.

El formulario

El primer formulario del sistema es el alta de productos. Es HTML, con dos atributos que lo conectan con PHP:

<form action="guardar-producto.php" method="post">
    <label for="codigo">Código</label>
    <input type="text" id="codigo" name="codigo" required>

    <label for="nombre">Nombre</label>
    <input type="text" id="nombre" name="nombre" required>

    <label for="categoria">Categoría</label>
    <select id="categoria" name="categoria">
        <option value="almacen">Almacén</option>
        <option value="bebidas">Bebidas</option>
        <option value="limpieza">Limpieza</option>
    </select>

    <label for="precio">Precio</label>
    <input type="number" id="precio" name="precio" step="0.01" min="0">

    <label for="stock">Stock</label>
    <input type="number" id="stock" name="stock" min="0">

    <button type="submit">Guardar</button>
</form>

Lo que no se puede olvidar

Atributo Para qué
action Qué archivo procesa. Si se omite, el navegador envía a la misma página.
method get o post. Si se omite, es get, que casi nunca es lo que se quiere.
name La clave con la que el dato llega a PHP. Sin name, el campo no se envía: el usuario lo completa y el servidor no se entera nunca.
id y for El for de la etiqueta tiene que coincidir con el id del campo. Es lo que hace que al tocar el texto se enfoque el campo, y lo que leen los lectores de pantalla.
type text, email, number, date, password… Cambia el teclado en el celular y la ayuda del navegador, pero no reemplaza la validación del servidor.

El nombre del campo y el de la clave en PHP son el mismo: un campo con name="precio" llega como $_POST['precio']. La convención que se usa en todo el sistema es minúsculas, sin acentos y con guion bajo si hace falta: nombre, precio, stock_minimo.

Enviar archivos

Si el formulario va a subir un archivo, además del method="post" hace falta enctype="multipart/form-data" y el campo tiene que ser <input type="file">. Sin el enctype, en el servidor llega el nombre del archivo como texto y nada más. Ese tema se ve en la lección 4.

Leer los datos

Del otro lado, el programa lee los campos de $_POST. El primer reflejo es escribir $precio = $_POST['precio'];, y eso funciona mientras el campo siempre venga. Cuando no viene, PHP avisa que la clave no existe y la variable queda en null; si después se usa en una cuenta, el total se arruina sin que se vea el motivo.

La forma correcta tiene tres pasos, y los tres importan:

<?php

declare(strict_types=1);

// 1. Leer sin romper si el campo no vino
$codigo = $_POST['codigo'] ?? '';

// 2. Quitar los espacios de los extremos
$codigo = trim($codigo);

// 3. Convertir al tipo que corresponde
$stock = (int) ($_POST['stock'] ?? 0);

Por qué trim() siempre

El usuario copia y pega. Un código que parece 1042 puede ser " 1042" con un espacio adelante, y entonces " 1042" === "1042" es falso. El error es invisible al mirar la pantalla y cuesta una tarde encontrarlo. trim() quita espacios, tabulaciones y saltos de línea de los dos extremos, y no toca los del medio: «Yerba mate» queda igual.

Por qué convertir el tipo

Lo que llega por un formulario es siempre texto, aunque el campo sea type="number". El navegador puede impedir que se escriban letras, pero no impide que alguien arme la petición a mano. Sin conversión, un precio "1850.50" funciona en la multiplicación —PHP lo convierte solo— y falla en el momento en que se compara con ===.

Con declare(strict_types=1) y una función que declara float, la conversión deja de ser opcional: si llega texto, el error aparece en la llamada y no tres pantallas después.

Validar en el servidor

Validar es comprobar que el dato sirve antes de usarlo. Hay dos niveles y no compiten: el navegador ayuda al usuario —marca en rojo un campo obligatorio y no lo deja enviar—, y el servidor decide. El del navegador se saltea abriendo las herramientas del desarrollador; el del servidor no.

Las reglas que se usan siempre

Qué se comprueba Cómo Ejemplo en el sistema
Que el dato llegó y no está vacío === '' El nombre del producto es obligatorio.
Que es del tipo esperado ctype_digit(), is_numeric() El stock es un entero; el precio, un número.
Que está en el rango Comparaciones El precio no puede ser negativo; el stock, tampoco.
Que tiene el largo adecuado mb_strlen() El nombre no puede pasar de 80 caracteres.
Que el formato es válido filter_var(..., FILTER_VALIDATE_EMAIL) El correo del cliente.
Que es una de las opciones permitidas in_array($valor, $lista, true) La categoría tiene que estar en el catálogo.

La validación completa del alta

<?php

declare(strict_types=1);

const CATEGORIAS = ['almacen', 'bebidas', 'limpieza', 'perfumeria'];

$codigo    = trim($_POST['codigo'] ?? '');
$nombre    = trim($_POST['nombre'] ?? '');
$categoria = $_POST['categoria'] ?? '';
$precio    = $_POST['precio'] ?? '';
$stock     = $_POST['stock'] ?? '';

$errores = [];

if ($codigo === '' || !ctype_digit($codigo)) {
    $errores['codigo'] = 'El código tiene que ser un número.';
}

if ($nombre === '') {
    $errores['nombre'] = 'El nombre es obligatorio.';
} elseif (mb_strlen($nombre) > 80) {
    $errores['nombre'] = 'El nombre no puede pasar de 80 caracteres.';
}

if (!in_array($categoria, CATEGORIAS, true)) {
    $errores['categoria'] = 'Elegí una categoría de la lista.';
}

if ($precio === '' || !is_numeric($precio)) {
    $errores['precio'] = 'El precio tiene que ser un número.';
} elseif ((float) $precio < 0) {
    $errores['precio'] = 'El precio no puede ser negativo.';
}

if ($stock === '' || !ctype_digit($stock)) {
    $errores['stock'] = 'El stock tiene que ser un número entero.';
}

Cada error se guarda en un array con la clave del campo. Eso permite mostrarlo al lado del campo que lo produjo, que es la única forma de que el mensaje sirva: «Revisá los datos» no le dice a nadie qué corregir.

Y la decisión de seguir es una sola línea: if ($errores === []). Todo lo que modifica el sistema va adentro de ese if. Es la estructura que evita el error clásico de guardar un producto con precio null porque la validación se hizo después de la consulta.

La categoría: no confiar ni en la lista

El <select> muestra cuatro opciones, pero el navegador no obliga a enviar una de ellas. Con las herramientas del desarrollador se cambia el value por cualquier cosa en dos segundos. Por eso la categoría se compara contra la lista blanca del servidor con in_array(..., true): si no está, se rechaza. Es el mismo criterio que se aplica a los roles y a los permisos en la lección 8.

Un detalle sobre in_array: el tercer argumento true es obligatorio. Sin él compara con ==, y una comparación floja en una lista blanca deja pasar valores que no deberían.

Mostrar los errores y no perder lo cargado

Cuando la validación falla, el programa no puede limitarse a decir «error»: tiene que volver a mostrar el formulario, con los mensajes al lado de cada campo y con lo que el usuario ya había escrito. Si pierde los datos, el usuario tiene que cargar otra vez siete campos por un error en uno.

La forma de lograrlo es escribir el valor en el atributo value de cada campo, escapado:

<label for="nombre">Nombre</label>
<input type="text" id="nombre" name="nombre"
       value="<?= htmlspecialchars($nombre, ENT_QUOTES, 'UTF-8') ?>">

<?php if (isset($errores['nombre'])): ?>
    <p class="text-danger small"><?= htmlspecialchars($errores['nombre'], ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>

Para el <select> el valor no se escribe, se compara: la opción que coincide con lo que llegó lleva selected.

<select id="categoria" name="categoria">
    <option value="">Elegí una</option>
    <?php foreach (CATEGORIAS as $opcion): ?>
        <option value="<?= $opcion ?>" <?= $categoria === $opcion ? 'selected' : '' ?>>
            <?= ucfirst($opcion) ?>
        </option>
    <?php endforeach; ?>
</select>

Los valores de CATEGORIAS son constantes escritas por el programador, así que no hace falta escaparlos. Pero si esa lista viniera de la base de datos, sí: la regla no cambia porque el dato hoy sea seguro.

El token del formulario

Un formulario en POST todavía tiene un agujero. Otra página puede tener un formulario escondido, apuntando al alta de productos del sistema, y enviarlo sola con JavaScript en cuanto el vendedor entra. El navegador manda la cookie de sesión junto con esa petición, el servidor no distingue una cosa de la otra y el producto se da de alta igual.

La defensa es un token: un valor aleatorio que el servidor guarda en la sesión y escribe en el formulario. Cuando la petición vuelve, se comparan. La otra página no puede saber el token del usuario, así que no puede armar una petición válida.

<?php
// Al preparar el formulario
if (empty($_SESSION['token'])) {
    $_SESSION['token'] = bin2hex(random_bytes(32));
}
?>
<form action="guardar-producto.php" method="post">
    <input type="hidden" name="token" value="<?= $_SESSION['token'] ?>">

Y al procesar, lo primero que se hace es comprobarlo:

<?php
if (!hash_equals($_SESSION['token'] ?? '', $_POST['token'] ?? '')) {
    http_response_code(419);
    exit('La sesión venció. Volvé a cargar el formulario.');
}

Tres detalles: random_bytes(32) genera 32 bytes al azar de una fuente criptográfica; bin2hex() los convierte en 64 caracteres; y la comparación se hace con hash_equals() en lugar de === porque esa función tarda lo mismo acierte o falle, y así no se puede deducir el token midiendo tiempos.

Después de guardar, redirigir

Cuando el alta sale bien, el programa no debe mostrar la página de «guardado con éxito». Si la muestra, el usuario aprieta F5 y el navegador reenvía el formulario: el producto se guarda dos veces. Y si el usuario retrocede y vuelve, el navegador le ofrece reenviar otra vez.

La solución se llama POST, redirección, GET: se procesa el POST, se redirige, y el navegador hace un GET a la página de destino. Esa página se puede recargar sin consecuencias.

<?php
if ($errores === []) {
    // ... guardar el producto ...

    header('Location: productos.php?alta=ok');
    exit;
}

Tres cosas no se pueden olvidar:

  • header() antes de cualquier salida. Si ya se imprimió algo —un espacio, una etiqueta—, el error es «headers already sent». Es el motivo por el que los archivos que solo tienen PHP no llevan etiqueta de cierre.
  • exit después. Sin él, PHP sigue ejecutando el resto del archivo: guarda el producto, redirige y además sigue con lo que había abajo.
  • La ruta del Location es absoluta o relativa al sitio, nunca al archivo actual. 'productos.php' y '/tienda/productos.php' funcionan; './productos.php' según desde dónde se llame.

El parámetro ?alta=ok es para mostrar el aviso en la página de destino. Ese valor llega por GET y se escapa al mostrarlo, como cualquier otro.

Cookies

Hasta acá, cada petición empieza de cero. Para recordar algo entre una página y la siguiente existen dos mecanismos, y conviene no confundirlos: las cookies se guardan en el navegador del usuario, y las sesiones se guardan en el servidor.

Una cookie es un par de nombre y valor que el servidor le pide al navegador que guarde. En cada petición posterior a ese sitio, el navegador la devuelve. En PHP:

setcookie('tema', 'oscuro', [
    'expires'  => time() + 60 * 60 * 24 * 30,   // 30 días
    'path'     => '/',
    'secure'   => true,      // solo por HTTPS
    'httponly' => true,      // el JavaScript de la página no puede leerla
    'samesite' => 'Lax',     // no se envía en peticiones de otros sitios
]);
Opción Qué evita
expiresSin fecha, la cookie dura hasta que se cierra el navegador.
httponlyQue un XSS lea la cookie con JavaScript.
secureQue la cookie viaje sin cifrar por HTTP.
samesiteQue la cookie se mande en peticiones originadas en otro sitio: es media defensa contra CSRF.

setcookie() manda una cabecera, así que tiene que ejecutarse antes de cualquier salida, igual que header(). Y las cookies que se escriben en una petición recién aparecen en $_COOKIE en la petición siguiente.

Sesiones

Una sesión resuelve el problema que dejó abierto la lección 1: no hay memoria entre una página y la siguiente. Lo que hace PHP es guardar un array en el servidor —$_SESSION— y darle al navegador una sola cookie con un identificador. En cada petición, PHP lee ese identificador, busca el array correspondiente y lo deja disponible.

<?php

declare(strict_types=1);

// Siempre al principio del archivo, antes de cualquier salida
session_start();

// Guardar
$_SESSION['usuario'] = 'vendedor1';
$_SESSION['rol'] = 'vendedor';

// Leer, sin romper si no está
$usuario = $_SESSION['usuario'] ?? null;

// Comprobar
if (!isset($_SESSION['usuario'])) {
    header('Location: login.php');
    exit;
}

El identificador y el robo de sesión

El identificador de sesión es la llave. Quien lo consiga puede hacerse pasar por el usuario: se copia la cookie en otro navegador y el servidor no distingue. Dos medidas, las dos de una línea, reducen el riesgo:

  • session_regenerate_id(true) al iniciar sesión: cambia el identificador y borra el archivo de la sesión anterior. Así, si alguien había conseguido el identificador previo, deja de servir.
  • Configurar la cookie de sesión con httponly, secure y samesite, igual que cualquier otra.

Guardar configuración de la sesión

session_set_cookie_params([
    'httponly' => true,
    'secure'   => true,
    'samesite' => 'Lax',
]);

session_start();

Tiene que ir antes de session_start(). Y el path por defecto es el de la carpeta del script, así que si el sistema vive en /tienda, la sesión no se comparte con el resto del sitio, que es lo deseable.

Cerrar sesión

Cerrar sesión no es solo borrar una clave: hay que vaciar el array, invalidar la cookie y destruir la sesión en el servidor.

<?php
$_SESSION = [];

if (ini_get('session.use_cookies')) {
    $p = session_get_cookie_params();
    setcookie(session_name(), '', [
        'expires'  => time() - 42000,
        'path'     => $p['path'],
        'httponly' => true,
        'secure'   => true,
        'samesite' => 'Lax',
    ]);
}

session_destroy();

header('Location: login.php');
exit;

Si se guarda la sesión en el servidor y no se destruye, el archivo queda ahí hasta que venza. Y si no se borra la cookie, el navegador sigue mandando un identificador que ya no existe: el usuario ve la pantalla de login, pero el servidor sigue recibiendo basura en cada petición.

El login del vendedor

Con todo lo anterior, el login del sistema se escribe en un archivo. El mismo archivo muestra el formulario cuando la petición es GET y lo procesa cuando es POST.

<?php

declare(strict_types=1);

session_set_cookie_params(['httponly' => true, 'samesite' => 'Lax']);
session_start();

// En el sistema real esto viene de la base de datos (lección 6).
// La clave de este usuario es: esquina2026
const USUARIOS = [
    'vendedor1' => [
        'hash' => '$2y$12$79ITnHymNoeM0m7EvD/PGu0KnuPRVXMVxjvIySSHmBB5Zw0vfbjFq',
        'rol'  => 'vendedor',
    ],
];

$error = '';
$usuario = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $usuario = trim($_POST['usuario'] ?? '');
    $clave = $_POST['clave'] ?? '';

    if ($usuario === '' || $clave === '') {
        $error = 'Completá usuario y contraseña.';
    } elseif (!isset(USUARIOS[$usuario])
        || !password_verify($clave, USUARIOS[$usuario]['hash'])) {
        // Mismo mensaje en los dos casos: no se revela qué cuentas existen
        $error = 'Usuario o contraseña incorrectos.';
    } else {
        session_regenerate_id(true);
        $_SESSION['usuario'] = $usuario;
        $_SESSION['rol'] = USUARIOS[$usuario]['rol'];

        header('Location: panel.php');
        exit;
    }
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="utf-8">
    <title>Ingreso · Tienda La Esquina</title>
</head>
<body>
    <h1>Tienda La Esquina</h1>

    <?php if ($error !== ''): ?>
        <p role="alert"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p>
    <?php endif; ?>

    <form action="login.php" method="post">
        <label for="usuario">Usuario</label>
        <input type="text" id="usuario" name="usuario" autocomplete="username"
               value="<?= htmlspecialchars($usuario, ENT_QUOTES, 'UTF-8') ?>" required>

        <label for="clave">Contraseña</label>
        <input type="password" id="clave" name="clave" autocomplete="current-password" required>

        <button type="submit">Ingresar</button>
    </form>
</body>
</html>

Las cuatro decisiones del ejemplo

Decisión Por qué
password_verify() y no === La contraseña nunca se guarda: se guarda su hash. password_verify() compara la clave con el hash sin necesidad de conocerla.
Mismo mensaje para usuario y clave Si el mensaje dijera «ese usuario no existe», cualquiera podría averiguar qué cuentas hay probando nombres.
session_regenerate_id(true) Corta el robo de sesión: el identificador que el usuario tenía antes de entrar deja de valer.
Redirigir después de entrar Si se muestra el panel como respuesta al POST, al recargar el navegador reenvía las credenciales.

Cómo se guarda una contraseña

El hash del ejemplo se genera una sola vez, en la máquina del programador, y se copia al sistema —o se guarda en la base cuando se crea el usuario—:

<?php

$hash = password_hash('esquina2026', PASSWORD_DEFAULT);

// $2y$12$... 60 caracteres, distinto cada vez que se ejecuta

var_dump(password_verify('esquina2026', $hash));   // bool(true)
var_dump(password_verify('otra', $hash));          // bool(false)

PASSWORD_DEFAULT usa el algoritmo recomendado en la versión que esté corriendo, así que el hash cambia de formato cuando PHP cambia de recomendación; por eso se guarda en un campo de 255 caracteres. El hash incluye el algoritmo, el costo y la sal: no hay que guardar nada más, ni inventar una sal propia.

Quién puede hacer qué

Estar logueado no es lo mismo que tener permiso. El vendedor carga ventas; el administrador mantiene el catálogo. Si el control se hace solo en el menú —ocultar el enlace—, cualquiera escribe la dirección a mano y entra igual. El control va en el servidor, en la primera línea de cada página protegida.

<?php
// productos-editar.php
session_start();

if (!isset($_SESSION['usuario'])) {
    header('Location: login.php');
    exit;
}

if (($_SESSION['rol'] ?? '') !== 'administrador') {
    http_response_code(403);
    exit('No tenés permiso para editar el catálogo.');
}

Los dos controles son distintos y los dos hacen falta: el primero corta a quien no entró, el segundo a quien entró pero no le corresponde. Y el rol se lee de la sesión, que vive en el servidor: no se puede cambiar editando una cookie.

Errores frecuentes

Lo que pasa La causa Cómo se arregla
$_POST llega vacío El formulario no tiene method="post", o los campos no tienen name. Revisar el method y que cada campo tenga name. Con GET, los datos están en $_GET.
Un campo llega con el nombre equivocado El name del HTML no coincide con la clave que lee el PHP. Copiar el mismo nombre en los dos lados. Es el error más común de todos.
Headers already sent Hay una salida antes de header() o de session_start(): un espacio, un BOM, una línea en blanco. Quitar la etiqueta de cierre en los archivos que son solo PHP y no dejar nada antes de la de apertura.
La sesión se pierde al cambiar de página No se llama a session_start() en todas las páginas, o se hace después de imprimir. Ponerlo en la primera línea de cada archivo que use la sesión.
El alta se guarda dos veces Se muestra el resultado en la misma respuesta del POST y el usuario recarga. Redirigir con header('Location: ...') y exit después de guardar.
El formulario se rompe con nombres que tienen comillas El valor se escribió en value sin escapar. htmlspecialchars($valor, ENT_QUOTES, 'UTF-8').
Una validación que debería rechazar y no rechaza Se comparó con == o con empty(), o se usó in_array sin true. Comparar con ===, distinguir '' de '0' y pasar true a in_array.
El login acepta cualquier contraseña Se compara la clave recibida con el hash usando ==, en lugar de password_verify(). Guardar el hash y comparar siempre con password_verify().
La página protegida se ve sin estar logueado El control está solo en el menú, o en el archivo que muestra el enlace y no en el que procesa. Comprobar la sesión y el rol en cada archivo que hace algo, no solo en el que se ve.

Casi todos estos errores se diagnostican mirando la petición: en las herramientas del desarrollador del navegador, la pestaña Red muestra qué se envió, con qué método, a qué dirección y qué respondió el servidor. Es la primera cosa que hay que mirar cuando algo «no anda», antes de tocar el código.

Ejercicios

Diez ejercicios sobre el sistema de la tienda, resolubles con lo que se vio en esta página. Los cinco primeros son de formularios y validación; los últimos agregan sesiones, permisos y login.

1. GET o POST

Para cada caso, decidir el método y justificar en una línea: buscar un producto por nombre, dar de alta un cliente, cambiar el precio de un producto, ver las ventas del día filtradas por vendedor, cerrar sesión.

2. El formulario del alta

Escribir el HTML del formulario de alta de clientes: nombre, correo, teléfono y dirección. Los dos primeros son obligatorios; el correo tiene que validarse como correo en el navegador.

3. Leer sin romper

Leer los cuatro campos del ejercicio anterior y dejarlos listos para validar, sin que aparezca ningún aviso si alguno no vino.

4. Validar el cliente

Escribir la validación completa: nombre obligatorio y de hasta 60 caracteres, correo válido y obligatorio, teléfono opcional pero numérico si viene, dirección opcional.

5. Volver a mostrar

Escribir el bloque HTML del campo nombre que muestra el error debajo y conserva lo cargado, con las clases de Bootstrap.

6. Token

Agregar el token CSRF al formulario del ejercicio 2 y escribir la comprobación que va al principio del archivo que lo procesa.

7. Guardar y redirigir

Completar el procesamiento: si no hay errores, guardar el cliente, redirigir a clientes.php?alta=ok y no ejecutar nada más.

8. Contador de visitas

Mostrar cuántas veces el usuario abrió la página en esta sesión, empezando en uno. Explicar por qué no sirve una variable común.

9. Página protegida

Escribir la cabecera de ventas.php, que solo puede ver el vendedor y el administrador, no el cliente.

10. Cerrar sesión bien

Escribir salir.php completo y explicar qué pasaría si solo se hiciera unset($_SESSION['usuario']).

Respuestas sugeridas

1. Buscar por nombre: GET, porque lee y la dirección se puede compartir. Alta de cliente: POST, porque crea. Cambiar el precio: POST, porque modifica. Ver ventas filtradas: GET, porque es una consulta y conviene que el filtro quede en la dirección. Cerrar sesión: POST —o al menos un enlace con token—, porque cambia el estado; por GET, otro sitio podría cerrarle la sesión al usuario con una imagen escondida.

2.

<form action="guardar-cliente.php" method="post">
    <label for="nombre">Nombre y apellido</label>
    <input type="text" id="nombre" name="nombre" maxlength="60" required>

    <label for="correo">Correo</label>
    <input type="email" id="correo" name="correo" required>

    <label for="telefono">Teléfono</label>
    <input type="tel" id="telefono" name="telefono">

    <label for="direccion">Dirección</label>
    <input type="text" id="direccion" name="direccion">

    <button type="submit">Guardar cliente</button>
</form>

3.

$nombre    = trim($_POST['nombre'] ?? '');
$correo    = trim($_POST['correo'] ?? '');
$telefono  = trim($_POST['telefono'] ?? '');
$direccion = trim($_POST['direccion'] ?? '');

Con ?? '', si el campo no vino la variable queda en texto vacío y la validación de «obligatorio» lo detecta. Sin eso, queda en null y el aviso aparece antes de poder decidir nada.

4.

$errores = [];

if ($nombre === '') {
    $errores['nombre'] = 'El nombre es obligatorio.';
} elseif (mb_strlen($nombre) > 60) {
    $errores['nombre'] = 'El nombre no puede pasar de 60 caracteres.';
}

if ($correo === '') {
    $errores['correo'] = 'El correo es obligatorio.';
} elseif (filter_var($correo, FILTER_VALIDATE_EMAIL) === false) {
    $errores['correo'] = 'El correo no tiene un formato válido.';
}

if ($telefono !== '' && !ctype_digit($telefono)) {
    $errores['telefono'] = 'El teléfono solo puede tener números.';
}

El teléfono es opcional, y por eso la condición empieza con !== '': solo se valida el formato si vino algo. La dirección no se valida porque cualquier texto sirve.

5.

<div class="mb-3">
    <label for="nombre" class="form-label">Nombre y apellido</label>
    <input type="text" id="nombre" name="nombre" maxlength="60"
           class="form-control <?= isset($errores['nombre']) ? 'is-invalid' : '' ?>"
           value="<?= htmlspecialchars($nombre, ENT_QUOTES, 'UTF-8') ?>">

    <?php if (isset($errores['nombre'])): ?>
        <div class="invalid-feedback">
            <?= htmlspecialchars($errores['nombre'], ENT_QUOTES, 'UTF-8') ?>
        </div>
    <?php endif; ?>
</div>

6.

<?php
session_start();

if (empty($_SESSION['token'])) {
    $_SESSION['token'] = bin2hex(random_bytes(32));
}
?>
<form action="guardar-cliente.php" method="post">
    <input type="hidden" name="token" value="<?= $_SESSION['token'] ?>">
    <!-- ... campos ... -->
</form>
<?php
// guardar-cliente.php, primeras líneas después de session_start()
if (!hash_equals($_SESSION['token'] ?? '', $_POST['token'] ?? '')) {
    http_response_code(419);
    exit('La sesión venció. Volvé a cargar el formulario.');
}

7.

if ($errores === []) {
    // ... insertar el cliente ...

    header('Location: clientes.php?alta=ok');
    exit;
}

El exit no es opcional: sin él, PHP sigue ejecutando el resto del archivo después de mandar la redirección.

8.

session_start();

$_SESSION['visitas'] = ($_SESSION['visitas'] ?? 0) + 1;

echo 'Visitas en esta sesión: ' . (int) $_SESSION['visitas'];

Una variable común no sirve porque no sobrevive a la petición: cada vez que se carga la página el programa empieza de cero y la variable vuelve a nacer. La sesión vive en el servidor y se recupera en cada petición usando el identificador de la cookie.

9.

<?php

declare(strict_types=1);

session_start();

if (!isset($_SESSION['usuario'])) {
    header('Location: login.php');
    exit;
}

if (!in_array($_SESSION['rol'] ?? '', ['vendedor', 'administrador'], true)) {
    http_response_code(403);
    exit('No tenés permiso para ver esta página.');
}

Los dos controles van antes de cualquier salida, incluido el DOCTYPE. Y el rol se compara con in_array(..., true): sin el true, la comparación es floja y una lista blanca deja de serlo.

10.

<?php

declare(strict_types=1);

session_start();

$_SESSION = [];

if (ini_get('session.use_cookies')) {
    $p = session_get_cookie_params();
    setcookie(session_name(), '', [
        'expires'  => time() - 42000,
        'path'     => $p['path'],
        'httponly' => true,
        'secure'   => true,
        'samesite' => 'Lax',
    ]);
}

session_destroy();

header('Location: login.php');
exit;

Con solo unset($_SESSION['usuario']) la sesión sigue viva: el archivo del servidor existe, el navegador conserva la cookie y cualquier otra clave que hubiera queda intacta. Además, si el control de acceso mira otra clave —el rol, por ejemplo—, el usuario seguiría pasando. Y en un sistema con token CSRF, el token anterior también sobreviviría.

Para leer más

  • Variables externas: $_POST, $_GET, $_COOKIE y qué hace PHP con cada una.
  • Sesiones: la referencia completa, con las opciones de configuración.
  • Filtros: filter_var y las validaciones que ya vienen hechas.
  • Hash de contraseñas: la guía oficial de password_hash().

Resumen

Tema Lo que hay que retener
Ida y vuelta El formulario y su procesamiento son dos peticiones distintas. Nada sobrevive entre una y otra.
GET y POST GET para leer, POST para escribir. Modificar por GET es la puerta de entrada del CSRF.
El formulario El name es la clave que llega a PHP. Sin name, el campo no existe del otro lado.
Leer ?? '' para no romper, trim() siempre, y conversión explícita al tipo.
Validar En el servidor, siempre. Errores por campo y una sola condición para seguir: $errores === [].
Mostrar de nuevo Conservar lo cargado, escapando el value con htmlspecialchars().
Token random_bytes en la sesión, campo oculto en el formulario, hash_equals() al volver.
Redirigir header('Location: …') y exit después de guardar, para que recargar no duplique.
Cookies Viven en el navegador y se editan. Nunca guardar ahí nada que el sistema tenga que creer.
Sesiones session_start() primero, session_regenerate_id(true) al entrar, y cerrar vaciando, borrando la cookie y destruyendo.
Contraseñas password_hash() al guardar, password_verify() al comprobar. Nunca se guarda la clave.
Permisos El control va en el servidor, en cada archivo. Ocultar un enlace no protege nada.

En la próxima lección el sistema deja de guardar todo en memoria: importa el catálogo desde un archivo, sube la foto de un producto y empieza a registrar lo que falla.

Autoevaluación de la lección 3

Licencia. El material de esta página es elaboración propia de Prof Mauro Bobyk —a partir de las fuentes que detalla el pie del sitio— y se publica bajo una licencia Creative Commons BY-NC-SA 4.0.