Saltar al contenido principal
Código PHP con un escudo de seguridad y un formulario HTML en pantalla.

Validación de datos en PHP: sanitizar y asegurar entradas de usuario

Cada formulario de tu aplicación es una puerta de entrada a tu sistema. Y cada campo de ese formulario es una oportunidad para que alguien malintencionado te mande código SQL, JavaScript, comandos de shell, o simplemente un email con un formato tan raro que revienta tu función de envío. Si no validás y sanitizás lo que te llega, no estás haciendo desarrollo web, estás jugando a la ruleta rusa con tu base de datos.

En este post vamos a recorrer filter_var, que es la función nativa de PHP dedicada a esto, y vamos a armar un set de funciones reutilizables que podés copiar a tus proyectos para tener una capa de validación consistente. No es la cobertura más exhaustiva del tema (hay libros enteros sobre seguridad web), pero es lo mínimo que necesitás para dormir tranquilo.

Validar vs sanitizar: no es lo mismo

Antes de meternos con código, hay una diferencia conceptual que confunde a mucha gente:

  • Validar es comprobar que un dato cumple con un formato esperado. ¿Es un email válido? ¿Es un número entero positivo? ¿Está la cadena dentro de cierto rango? La validación responde con “sí” o “no”.
  • Sanitizar es limpiar un dato para hacerlo seguro, removiendo o escapando los caracteres problemáticos. La sanitización transforma el dato.

Por ejemplo: si el usuario escribe " juan@email.com ", sanitizar te devuelve "juan@email.com" (sin espacios) y validar te dice que el formato es correcto. Si escribe "<script>alert('xss')</script>", sanitizar te devuelve un string inofensivo (con los tags escapados o removidos) y validar te dice que no es un formato aceptable para un campo de email.

El orden correcto siempre es: primero sanitizar, después validar. Si sanitizás bien, la validación va a pasar en la mayoría de los casos legítimos. Si no sanitizás, terminás rechazando emails válidos con espacios o rechazando entradas que eran perfectamente seguras.

filter_var: el caballito de batalla

PHP trae una función专门 pensada para esto: filter_var(). Recibe el valor a procesar, un ID de filtro, y opciones opcionales. La misma función sirve para validar y para sanitizar, lo que cambia es el ID del filtro.

filter_var($valor, FILTER_VALIDATE_EMAIL);
filter_var($valor, FILTER_SANITIZE_EMAIL);

Empecemos por la sanitización, que es donde menos se mete la gente.

Sanitizar strings

El caso más común. Tenés un input de texto libre y querés sacarle todo lo que no debería estar ahí. El filtro FILTER_SANITIZE_FULL_SPECIAL_CHARS escapa HTML, comillas y otros caracteres especiales, y es ideal para datos que vas a mostrar después en una página.

$nombre = "<script>alert('hola')</script>";
$limpio = filter_var($nombre, FILTER_SANITIZE_FULL_SPECIAL_CHARS);

echo $limpio;
// "&lt;script&gt;alert('hola')&lt;/script&gt;"

El string original sigue ahí, pero escapado. Cuando lo renderices en HTML, el navegador lo va a mostrar como texto literal en lugar de ejecutarlo como JavaScript. Esto por sí solo ya te protege de la mayoría de los ataques XSS reflejados.

Otros filtros de sanitización útiles:

// Quitar tags HTML completos (más agresivo)
$limpio = filter_var($texto, FILTER_SANITIZE_STRING); // PHP 8.1+ deprecado

// Eliminar todo lo que no sea letras, números y espacios
$limpio = filter_var($texto, FILTER_SANITIZE_NUMBER_INT);

// Quitar caracteres no numéricos (deja el punto y el signo menos)
$precio = filter_var("$1,250.50", FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION);
echo $precio; // "1250.50"

// Limpiar un email (quita caracteres que no son válidos en emails)
$email = filter_var("  juan@@email .com  ", FILTER_SANITIZE_EMAIL);
echo $email; // "juan@email.com"

// Limpiar una URL (elimina caracteres no permitidos)
$url = filter_var("https://ejemplo.com/path<script>", FILTER_SANITIZE_URL);
echo $url; // "https://ejemplo.com/pathscript"

Un detalle: FILTER_SANITIZE_STRING está deprecado desde PHP 8.1. Si todavía lo usás, conviene migrar a htmlspecialchars() o strip_tags() según el caso. El primero escapa, el segundo elimina. Acá una comparación rápida:

$texto = "<p>Hola <b>mundo</b></p>";

echo htmlspecialchars($texto);
// "&lt;p&gt;Hola &lt;b&gt;mundo&lt;/b&gt;&lt;/p&gt;"

echo strip_tags($texto);
// "Hola mundo"

strip_tags es más agresivo y borra todo el HTML, útil para campos tipo “nombre” donde no querés ningún tag. htmlspecialchars es más conservador, útil para campos como “comentario” donde querés permitir algo de formato pero sin que se ejecute.

Validar emails

Este es probablemente el filtro que más vas a usar. Validar emails con filter_var es de una línea:

$email = "juan@ejemplo.com";

if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
    echo "Email válido";
} else {
    echo "Email inválido";
}

El filtro FILTER_VALIDATE_EMAIL chequea que el formato sea correcto (usuario + @ + dominio + . + TLD) y que no haya caracteres inválidos. Es bastante estricto y rechaza emails malformados, pero ojo: no verifica que el dominio exista ni que la casilla esté activa. Para eso necesitás un servicio externo o mandar un email de confirmación con un link.

Casos reales que este filtro rechaza:

filter_var("juan@", FILTER_VALIDATE_EMAIL);              // false (sin dominio)
filter_var("@ejemplo.com", FILTER_VALIDATE_EMAIL);       // false (sin usuario)
filter_var("juan@@ejemplo.com", FILTER_VALIDATE_EMAIL);  // false (doble @)
filter_var("juan espacio@ejemplo.com", FILTER_VALIDATE_EMAIL); // false (espacio)
filter_var("ju|an@ejemplo.com", FILTER_VALIDATE_EMAIL);  // false (carácter inválido)

Casos que sí pasa y que pueden ser válidos:

filter_var("juan+etiqueta@gmail.com", FILTER_VALIDATE_EMAIL);     // válido
filter_var("juan.perez@sub.ejemplo.com", FILTER_VALIDATE_EMAIL);  // válido
filter_var("a@b.io", FILTER_VALIDATE_EMAIL);                       // válido

Validar URLs

Para URLs el filtro es FILTER_VALIDATE_URL. Valida que el formato sea correcto, pero no chequea que el dominio exista ni que la URL responda. Sirve para descartar basura obvia antes de hacer una request o de guardar el dato.

$url = "https://blivecode.com/blog/php";

if (filter_var($url, FILTER_VALIDATE_URL)) {
    echo "URL con formato válido";
}

Por defecto acepta varios esquemas (http, https, ftp, etc). Si querés restringir a https solamente (recomendado para sitios modernos), usá la bandera FILTER_FLAG_PATH_REQUIRED:

$opciones = FILTER_FLAG_PATH_REQUIRED;
filter_var("https://ejemplo.com", FILTER_VALIDATE_URL, $opciones); // false (sin path)
filter_var("https://ejemplo.com/", FILTER_VALIDATE_URL, $opciones); // válido

Y si querés ser más estricto con el query string:

filter_var("https://ejemplo.com/path?q=1", FILTER_VALIDATE_URL, FILTER_FLAG_QUERY_REQUIRED);
// válido, porque tiene query string

Validar números

Acá hay tres filtros: FILTER_VALIDATE_INT, FILTER_VALIDATE_FLOAT y FILTER_VALIDATE_REGEXP. Los dos primeros aceptan banderas para forzar rangos.

filter_var("42", FILTER_VALIDATE_INT);          // 42
filter_var("42.5", FILTER_VALIDATE_INT);        // false (es float)
filter_var("42abc", FILTER_VALIDATE_INT);       // false (no es puro número)
filter_var("-10", FILTER_VALIDATE_INT);         // -10 (acepta negativos)

// Forzar que sea positivo
filter_var("-10", FILTER_VALIDATE_INT, ["options" => ["min_range" => 0]]); // false
filter_var("10", FILTER_VALIDATE_INT, ["options" => ["min_range" => 0, "max_range" => 100]]); // 10

Para floats es igual pero con FILTER_VALIDATE_FLOAT y la bandera FILTER_FLAG_ALLOW_THOUSAND para aceptar separadores de miles:

filter_var("1,250.50", FILTER_VALIDATE_FLOAT, FILTER_FLAG_ALLOW_THOUSAND); // 1250.5
filter_var("1250.50", FILTER_VALIDATE_FLOAT); // 1250.5

El problema clásico con $_GET es que todo llega como string. Si esperás un entero de la query string y el usuario te manda "abc", $_GET['id'] te va a llegar como string, no como false. Por eso es importante siempre castear y validar:

$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT, ["options" => ["min_range" => 1]]);
if ($id === false || $id === null) {
    http_response_code(400);
    echo "ID inválido";
    exit;
}

filter_input es hermano de filter_var pero toma el valor directamente de $_GET, $_POST o $_COOKIE. Si la variable no existe, devuelve null en lugar de false, así que chequeá ambos.

Validar IPs y dominios

Si en algún momento necesitás validar que una IP tenga formato correcto (por ejemplo, para un sistema de logs o un panel de admin que filtra por IP), hay dos filtros专门 para eso:

filter_var("192.168.1.1", FILTER_VALIDATE_IP);        // "192.168.1.1"
filter_var("256.1.1.1", FILTER_VALIDATE_IP);          // false (fuera de rango)
filter_var("::1", FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); // "::1"

Para dominios (ejemplo.com sin path ni protocolo), usá FILTER_VALIDATE_DOMAIN. Sirve para casos tipo “el usuario configura su propio subdominio y querés validar el formato antes de guardarlo”.

filter_var("ejemplo.com", FILTER_VALIDATE_DOMAIN);        // "ejemplo.com"
filter_var("sub.ejemplo.com", FILTER_VALIDATE_DOMAIN);    // válido
filter_var("ejemplo.com/path", FILTER_VALIDATE_DOMAIN);   // false (tiene path)

Funciones reutilizables para tus proyectos

Tener filter_var en cada lugar está bien, pero si lo repetís 20 veces en un mismo proyecto, vale la pena envolverlo en funciones专门 para tu dominio. Acá te dejo un set que uso de base y voy extendiendo según cada proyecto.

class Validador
{
    public static function email(string $valor, bool $obligatorio = true): bool|string
    {
        $valor = trim($valor);
        if (empty($valor) && !$obligatorio) {
            return '';
        }
        if (empty($valor) && $obligatorio) {
            return 'El email es obligatorio';
        }
        $valor = filter_var($valor, FILTER_SANITIZE_EMAIL);
        if (!filter_var($valor, FILTER_VALIDATE_EMAIL)) {
            return 'El email no tiene un formato válido';
        }
        return true; // válido, sin error
    }

    public static function texto(string $valor, int $min = 1, int $max = 255, bool $obligatorio = true): bool|string
    {
        $valor = trim($valor);
        $longitud = mb_strlen($valor);
        if ($longitud === 0 && !$obligatorio) {
            return '';
        }
        if ($longitud === 0 && $obligatorio) {
            return 'Este campo es obligatorio';
        }
        if ($longitud < $min) {
            return "Mínimo $min caracteres";
        }
        if ($longitud > $max) {
            return "Máximo $max caracteres";
        }
        return true;
    }

    public static function entero(mixed $valor, int $min = PHP_INT_MIN, int $max = PHP_INT_MAX, bool $obligatorio = true): bool|string
    {
        if (($valor === null || $valor === '' || $valor === false) && !$obligatorio) {
            return '';
        }
        if (($valor === null || $valor === '' || $valor === false) && $obligatorio) {
            return 'Este campo es obligatorio';
        }
        $valor = filter_var($valor, FILTER_VALIDATE_INT);
        if ($valor === false) {
            return 'Debe ser un número entero';
        }
        if ($valor < $min) {
            return "El valor mínimo es $min";
        }
        if ($valor > $max) {
            return "El valor máximo es $max";
        }
        return true;
    }

    public static function url(string $valor, bool $obligatorio = true): bool|string
    {
        $valor = trim($valor);
        if (empty($valor) && !$obligatorio) {
            return '';
        }
        if (empty($valor) && $obligatorio) {
            return 'La URL es obligatoria';
        }
        $valor = filter_var($valor, FILTER_SANITIZE_URL);
        if (!filter_var($valor, FILTER_VALIDATE_URL)) {
            return 'La URL no tiene un formato válido';
        }
        return true;
    }

    public static function password(string $valor, int $min = 8, bool $obligatorio = true): bool|string
    {
        if (empty($valor) && !$obligatorio) {
            return '';
        }
        if (empty($valor) && $obligatorio) {
            return 'La contraseña es obligatoria';
        }
        if (strlen($valor) < $min) {
            return "Mínimo $min caracteres";
        }
        return true;
    }

    public static function fecha(string $valor, string $formato = 'Y-m-d', bool $obligatorio = true): bool|string
    {
        $valor = trim($valor);
        if (empty($valor) && !$obligatorio) {
            return '';
        }
        if (empty($valor) && $obligatorio) {
            return 'La fecha es obligatoria';
        }
        $objeto = \DateTime::createFromFormat($formato, $valor);
        if (!$objeto || $objeto->format($formato) !== $valor) {
            return "La fecha no tiene el formato esperado ($formato)";
        }
        return true;
    }
}

La convención que uso es: si la validación pasa, la función devuelve true. Si falla, devuelve un string con el mensaje de error. Eso permite usarla directamente en formularios HTML o en APIs JSON. La validación de fecha es más estricta porque usa DateTime::createFromFormat y compara el string original con el formato, evitando el bug clásico de que 2026-13-45 sea aceptado por un parser laxo.

Uso típico en un formulario

$errores = [];

$resultado = Validador::email($_POST['email'] ?? '');
if ($resultado !== true) {
    $errores['email'] = $resultado;
}

$resultado = Validador::texto($_POST['nombre'] ?? '', 2, 50);
if ($resultado !== true) {
    $errores['nombre'] = $resultado;
}

$resultado = Validador::entero($_POST['edad'] ?? null, 18, 120);
if ($resultado !== true) {
    $errores['edad'] = $resultado;
}

if (empty($errores)) {
    // Todo OK, procesar el formulario
    $email = filter_var($_POST['email'], FILTER_SANITIZE_EMAIL);
    $nombre = htmlspecialchars(trim($_POST['nombre']), ENT_QUOTES, 'UTF-8');
    $edad = (int) $_POST['edad'];

    // ... guardar en BD, mandar email, etc.
} else {
    // Mostrar errores al usuario
    foreach ($errores as $campo => $mensaje) {
        echo "<p><strong>$campo:</strong> $mensaje</p>";
    }
}

Notá que después de validar, vuelvo a sanitizar los valores que voy a usar. La validación me dijo “el formato es OK”, la sanitización me deja el valor listo para usar. Esa doble pasada es la que evita sorpresas.

Sanitización final antes de guardar o mostrar

Aunque hayas validado, siempre sanitizá el output. El mantra clásico de seguridad es “no confíes en el input, no confíes en el output” y la idea es escapar todo lo que llega del usuario antes de mostrarlo en HTML, antes de mandarlo a la base de datos, antes de escribirlo en un log. Cada destino tiene su propio escape.

// Antes de guardar en BD (usá prepared statements, esto es solo defensa adicional)
$valorParaBD = htmlspecialchars(strip_tags($input), ENT_QUOTES, 'UTF-8');

// Antes de mostrar en HTML
$valorParaHTML = htmlspecialchars($input, ENT_QUOTES, 'UTF-8');

// Antes de escribir en un log
$valorParaLog = strip_tags(str_replace(["\n", "\r"], ' ', $input));

// Antes de usar en una query de SQL (lo correcto es prepared statement)
$valorParaSQL = addslashes($input); // NO recomendado, usar PDO con prepare

htmlspecialchars con ENT_QUOTES y UTF-8 es la combinación que uso siempre que voy a mostrar texto en HTML. La flag ENT_QUOTES escapa comillas simples y dobles, lo que cierra el agujero típico de atributos HTML mal escapados.

Errores comunes que vale la pena evitar

  • Validar en cliente y confiar en que está validado en servidor. JavaScript se desactiva. La validación en cliente es solo para UX, nunca para seguridad.
  • Usar empty() para validar. empty("0") devuelve true, así que el string "0" lo toma como vacío. Esto es fuente de bugs clásicos en formularios con selects o radios.
  • Olvidar la zona horaria al validar fechas. Si recibís "2026-06-27" y tu servidor está en otra zona, podés tener problemas al guardarlo. Mejor trabajar en UTC.
  • Confiar en FILTER_VALIDATE_EMAIL para saber si un email existe. Solo chequea formato, no existencia. Para eso mandá un email de confirmación.
  • Hacer sanitización pero no validación, o viceversa. Son complementarios, no excluyentes.

Conclusión

filter_var no es la herramienta más sexy de PHP, pero es de las más útiles. Tener un set de funciones de validación reutilizables te ahorra horas de debug y te protege de los ataques más comunes sin tener que instalar librerías externas pesadas.

La recomendación es empezar con una clase simple como la que te mostré, copiarla a tus proyectos, y extenderla cuando necesites reglas de negocio más específicas (por ejemplo, validar que un CUIT sea válido, que un código postal exista en una base de datos, o que un nombre de usuario no esté en una lista negra). El 80% de los formularios del mundo real se cubren con email, texto, entero, url y fecha.

En el próximo post vamos a hablar de arrays y las funciones que más uso para procesarlos. Si tenés alguna función de validación que uses seguido y no esté acá, me encantaría leerla en los comentarios.