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:
- El servidor entrega una página con un formulario. Es una respuesta HTTP común, con etiquetas
<form>adentro. - El usuario completa los campos y aprieta el botón.
- El navegador arma una nueva petición con esos datos y la envía a la dirección que dice el formulario.
- 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; ?>
value se escapa siempre. Si no, un nombre de producto con una comilla cierra el atributo y rompe el formulario; con etiquetas adentro, ejecuta código en el navegador de quien lo abre. Es el mismo XSS de la lección 1, ahora en un atributo y no en el texto.
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.exitdespué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
Locationes 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.
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,secureysamesite, 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,$_COOKIEy qué hace PHP con cada una. - Sesiones: la referencia completa, con las opciones de configuración.
- Filtros:
filter_vary 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.
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.