Skip to content

Formularios y validación ​

Los formularios son la forma en que tus usuarios hablan con tu servidor: contacto, registro, login, búsqueda, pedidos. En esta lección aprenderás a recibir datos con GET y POST, a validarlos correctamente en el servidor, a mostrar errores junto a cada campo y a evitar el reenvío accidental con el patrón PRG.

Concepto ​

Un formulario es como una solicitud en papel que entregas en una ventanilla. El empleado (PHP) debe:

  1. Recibir la hoja.
  2. Revisar que esté completa y bien llenada (validar).
  3. Si hay errores, devolvértela marcando qué corregir, sin borrar lo que ya escribiste.
  4. Si todo está bien, procesarla y darte un comprobante.

GET frente a POST ​

GETPOST
Dónde viajan los datosEn la URL: buscar.php?q=zapatosEn el cuerpo de la petición
Visible / se puede guardar como marcadorSíNo
Límite de tamaño~2000 caracteresGrande (configurable)
Úsalo paraBúsquedas, filtros, paginaciónCrear, modificar o borrar datos; login; archivos

Regla de oro: si la acción cambia algo (guarda, borra, envía un correo), usa POST.

En PHP los datos llegan en las superglobales $_GET y $_POST, que son arrays asociativos cuyas claves son los atributos name de los campos.

Ejemplo mínimo: búsqueda con GET ​

php
<?php
// buscar.php
$q = trim($_GET['q'] ?? ''); // ?? evita el warning si no viene el parámetro
?>
<form method="get" action="buscar.php">
    <input type="search" name="q" value="<?= htmlspecialchars($q) ?>" placeholder="Buscar...">
    <button>Buscar</button>
</form>

<?php if ($q !== ''): ?>
    <p>Resultados para: <strong><?= htmlspecialchars($q) ?></strong></p>
<?php endif; ?>

Observa que repintamos el valor en el value del input, escapado con htmlspecialchars.

Validar en el servidor: siempre ​

Los atributos HTML como required, type="email" o maxlength son comodidad para el usuario, pero cualquiera puede saltárselos (abriendo las herramientas del navegador o enviando la petición con otra herramienta). La validación real siempre se hace en PHP.

Pasos de una buena validación:

  1. Normalizar: trim(), convertir tipos.
  2. Validar: ¿obligatorio? ¿formato correcto? ¿longitud? ¿rango?
  3. Recolectar errores por campo en un array.
  4. Si hay errores, volver a mostrar el formulario con los valores y los mensajes.
  5. Si no hay errores, procesar.

filter_var: el validador incorporado ​

php
<?php
var_dump(filter_var('ana@correo.com', FILTER_VALIDATE_EMAIL)); // 'ana@correo.com'
var_dump(filter_var('ana@', FILTER_VALIDATE_EMAIL));           // false

var_dump(filter_var('42', FILTER_VALIDATE_INT));               // int(42)
var_dump(filter_var('4.2', FILTER_VALIDATE_INT));              // false

// Entero con rango
$edad = filter_var('17', FILTER_VALIDATE_INT, [
    'options' => ['min_range' => 18, 'max_range' => 120],
]); // false

var_dump(filter_var('https://misitio.mx', FILTER_VALIDATE_URL)); // la URL
var_dump(filter_var('sí', FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)); // null
var_dump(filter_var('on', FILTER_VALIDATE_BOOLEAN));            // true (checkbox)

Ejemplo completo: formulario de contacto con validación ​

php
<?php
// contacto.php
declare(strict_types=1);

function e(?string $t): string
{
    return htmlspecialchars($t ?? '', ENT_QUOTES, 'UTF-8');
}

$servicios = ['web' => 'Página web', 'tienda' => 'Tienda en línea', 'seo' => 'SEO'];

// Valores iniciales (formulario vacío)
$datos = ['nombre' => '', 'email' => '', 'telefono' => '', 'servicio' => '', 'mensaje' => '', 'acepto' => false];
$errores = [];
$enviado = false;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // 1. Normalizar
    $datos['nombre']   = trim($_POST['nombre'] ?? '');
    $datos['email']    = trim($_POST['email'] ?? '');
    $datos['telefono'] = preg_replace('/\D/', '', $_POST['telefono'] ?? ''); // Sólo dígitos
    $datos['servicio'] = $_POST['servicio'] ?? '';
    $datos['mensaje']  = trim($_POST['mensaje'] ?? '');
    $datos['acepto']   = isset($_POST['acepto']); // Un checkbox sin marcar NO se envía

    // 2. Validar campo por campo
    if ($datos['nombre'] === '') {
        $errores['nombre'] = 'Escribe tu nombre.';
    } elseif (mb_strlen($datos['nombre']) > 80) {
        $errores['nombre'] = 'El nombre es demasiado largo (máx. 80).';
    }

    if (!filter_var($datos['email'], FILTER_VALIDATE_EMAIL)) {
        $errores['email'] = 'Escribe un correo válido.';
    }

    // Teléfono opcional, pero si viene debe tener 10 dígitos
    if ($datos['telefono'] !== '' && strlen($datos['telefono']) !== 10) {
        $errores['telefono'] = 'El teléfono debe tener 10 dígitos.';
    }

    // Lista blanca: sólo valores que nosotros ofrecemos
    if (!array_key_exists($datos['servicio'], $servicios)) {
        $errores['servicio'] = 'Elige un servicio de la lista.';
    }

    $largo = mb_strlen($datos['mensaje']);
    if ($largo < 10 || $largo > 2000) {
        $errores['mensaje'] = 'El mensaje debe tener entre 10 y 2000 caracteres.';
    }

    if (!$datos['acepto']) {
        $errores['acepto'] = 'Debes aceptar el aviso de privacidad.';
    }

    // 3. Procesar si todo está bien
    if (empty($errores)) {
        // Aquí guardarías en la base de datos o enviarías un correo (lecciones 19 y 21).
        $linea = date('c') . ' | ' . json_encode($datos, JSON_UNESCAPED_UNICODE) . PHP_EOL;
        file_put_contents(__DIR__ . '/mensajes.log', $linea, FILE_APPEND | LOCK_EX);
        $enviado = true;
    }
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>Contacto</title>
    <style>
        body { font-family: system-ui; max-width: 560px; margin: 2rem auto; padding: 0 1rem; }
        label { display: block; margin-top: 1rem; font-weight: 600; }
        input, select, textarea { width: 100%; padding: .5rem; box-sizing: border-box; }
        .error { color: #c0392b; font-size: .9rem; }
        .campo-error { border: 2px solid #c0392b; }
        .exito { background: #e8f8ef; padding: 1rem; border-radius: 8px; }
    </style>
</head>
<body>
<h1>Contáctanos</h1>

<?php if ($enviado): ?>
    <p class="exito">¡Gracias, <?= e($datos['nombre']) ?>! Te responderemos pronto.</p>
<?php else: ?>
    <?php if ($errores): ?>
        <p class="error">Revisa los campos marcados.</p>
    <?php endif; ?>

    <form method="post" action="contacto.php" novalidate>
        <label for="nombre">Nombre *</label>
        <input id="nombre" name="nombre" value="<?= e($datos['nombre']) ?>"
               class="<?= isset($errores['nombre']) ? 'campo-error' : '' ?>">
        <?php if (isset($errores['nombre'])): ?>
            <span class="error"><?= e($errores['nombre']) ?></span>
        <?php endif; ?>

        <label for="email">Correo *</label>
        <input id="email" name="email" type="email" value="<?= e($datos['email']) ?>"
               class="<?= isset($errores['email']) ? 'campo-error' : '' ?>">
        <?php if (isset($errores['email'])): ?>
            <span class="error"><?= e($errores['email']) ?></span>
        <?php endif; ?>

        <label for="telefono">Teléfono</label>
        <input id="telefono" name="telefono" value="<?= e($datos['telefono']) ?>">
        <?php if (isset($errores['telefono'])): ?>
            <span class="error"><?= e($errores['telefono']) ?></span>
        <?php endif; ?>

        <label for="servicio">Servicio *</label>
        <select id="servicio" name="servicio">
            <option value="">— Elige —</option>
            <?php foreach ($servicios as $valor => $texto): ?>
                <!-- Mantiene seleccionada la opción elegida -->
                <option value="<?= e($valor) ?>" <?= $datos['servicio'] === $valor ? 'selected' : '' ?>>
                    <?= e($texto) ?>
                </option>
            <?php endforeach; ?>
        </select>
        <?php if (isset($errores['servicio'])): ?>
            <span class="error"><?= e($errores['servicio']) ?></span>
        <?php endif; ?>

        <label for="mensaje">Mensaje *</label>
        <textarea id="mensaje" name="mensaje" rows="5"><?= e($datos['mensaje']) ?></textarea>
        <?php if (isset($errores['mensaje'])): ?>
            <span class="error"><?= e($errores['mensaje']) ?></span>
        <?php endif; ?>

        <label>
            <input type="checkbox" name="acepto" <?= $datos['acepto'] ? 'checked' : '' ?> style="width:auto">
            Acepto el aviso de privacidad
        </label>
        <?php if (isset($errores['acepto'])): ?>
            <span class="error"><?= e($errores['acepto']) ?></span>
        <?php endif; ?>

        <p><button type="submit">Enviar</button></p>
    </form>
<?php endif; ?>
</body>
</html>

Puntos clave del ejemplo:

  • $_SERVER['REQUEST_METHOD'] === 'POST' distingue "mostrar formulario" de "procesar envío".
  • Cada error se guarda con la clave del campo, para mostrarlo debajo del campo correcto.
  • Los valores se repintan escapados (e()), incluyendo selected y checked.
  • El novalidate del formulario desactiva la validación del navegador para que puedas probar la del servidor.

Error común

Un checkbox sin marcar no se envía: $_POST['acepto'] no existe. Usa isset($_POST['acepto']), no $_POST['acepto'] === 'on' sin comprobar antes.

Arrays desde formularios ​

Si el name termina en [], PHP lo convierte en array:

html
<label><input type="checkbox" name="intereses[]" value="diseno"> Diseño</label>
<label><input type="checkbox" name="intereses[]" value="marketing"> Marketing</label>
<label><input type="checkbox" name="intereses[]" value="desarrollo"> Desarrollo</label>
php
<?php
$permitidos = ['diseno', 'marketing', 'desarrollo'];
$intereses = $_POST['intereses'] ?? [];

// Asegurarnos de que es un array y sólo contiene valores permitidos
$intereses = is_array($intereses) ? array_values(array_intersect($intereses, $permitidos)) : [];

Patrón PRG: Post / Redirect / Get ​

Si después de procesar un POST muestras la página directamente, al recargar el navegador pregunta "¿Reenviar formulario?" y el usuario puede duplicar el pedido o el mensaje. La solución: tras procesar con éxito, redirige.

php
<?php
// pedido.php
session_start();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // ... validar y guardar ...
    if (empty($errores)) {
        $_SESSION['flash'] = 'Pedido registrado correctamente.';
        header('Location: pedido.php'); // Redirige con un GET
        exit;                           // ¡Siempre exit después de header Location!
    }
}

// Mostrar el mensaje una sola vez
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);

Las sesiones y los mensajes flash los verás en detalle en la siguiente lección.

Buena práctica

Valida con listas blancas (qué está permitido) en vez de listas negras (qué está prohibido). Es imposible imaginar todas las entradas maliciosas; es fácil definir las válidas.

Seguridad

Escapar al mostrar (htmlspecialchars) evita XSS; usar consultas preparadas evita inyección SQL; y los formularios POST importantes necesitan un token CSRF. Lo cubrimos en CSRF y Validación y sanitización.

Un validador reutilizable ​

Cuando tienes muchos formularios, conviene una pequeña función de reglas:

php
<?php
declare(strict_types=1);

/**
 * Valida $datos según $reglas. Devuelve [campo => mensaje].
 * Reglas soportadas: requerido, email, min:N, max:N, numerico, en:a,b,c
 */
function validar(array $datos, array $reglas): array
{
    $errores = [];
    foreach ($reglas as $campo => $listaReglas) {
        $valor = trim((string) ($datos[$campo] ?? ''));
        foreach (explode('|', $listaReglas) as $regla) {
            [$nombre, $param] = array_pad(explode(':', $regla, 2), 2, null);

            $error = match ($nombre) {
                'requerido' => $valor === '' ? 'Este campo es obligatorio.' : null,
                'email'     => $valor !== '' && !filter_var($valor, FILTER_VALIDATE_EMAIL) ? 'Correo no válido.' : null,
                'min'       => mb_strlen($valor) < (int) $param ? "Mínimo $param caracteres." : null,
                'max'       => mb_strlen($valor) > (int) $param ? "Máximo $param caracteres." : null,
                'numerico'  => $valor !== '' && !is_numeric($valor) ? 'Debe ser un número.' : null,
                'en'        => !in_array($valor, explode(',', $param), true) ? 'Opción no válida.' : null,
                default     => throw new InvalidArgumentException("Regla desconocida: $nombre"),
            };

            if ($error !== null) {
                $errores[$campo] = $error;
                break; // Un error por campo es suficiente
            }
        }
    }
    return $errores;
}

// Uso
$errores = validar($_POST, [
    'nombre'  => 'requerido|max:80',
    'email'   => 'requerido|email',
    'edad'    => 'numerico',
    'plan'    => 'requerido|en:basico,pro,empresa',
    'mensaje' => 'requerido|min:10|max:2000',
]);

Es la misma idea que usa Laravel con $request->validate() (lo verás en Validación en Laravel).

Ejercicios ​

  1. Crea un formulario de registro con nombre, correo, contraseña y confirmación de contraseña. Valida que las contraseñas coincidan y tengan al menos 8 caracteres.
  2. Crea una calculadora de IMC (peso en kg y estatura en metros) que valide que ambos sean números positivos y muestre el resultado con su clasificación.
  3. Añade al validador reutilizable la regla confirmado (el campo password debe ser igual a password_confirmacion).
Ver solución
php
<?php
declare(strict_types=1);

$errores = [];
$nombre = trim($_POST['nombre'] ?? '');
$email = trim($_POST['email'] ?? '');
$pass = $_POST['password'] ?? '';        // Las contraseñas NO se recortan con trim
$pass2 = $_POST['password2'] ?? '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if ($nombre === '') $errores['nombre'] = 'Nombre obligatorio.';
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errores['email'] = 'Correo no válido.';
    if (mb_strlen($pass) < 8) $errores['password'] = 'Mínimo 8 caracteres.';
    if ($pass !== $pass2) $errores['password2'] = 'Las contraseñas no coinciden.';

    if (!$errores) {
        echo 'Registro válido. (En la lección de PDO lo guardaremos con password_hash)';
        exit;
    }
}
?>
<form method="post">
    <input name="nombre" placeholder="Nombre" value="<?= htmlspecialchars($nombre) ?>">
    <small><?= $errores['nombre'] ?? '' ?></small>
    <input name="email" placeholder="Correo" value="<?= htmlspecialchars($email) ?>">
    <small><?= $errores['email'] ?? '' ?></small>
    <!-- Nunca repintes la contraseña en el value -->
    <input name="password" type="password" placeholder="Contraseña">
    <small><?= $errores['password'] ?? '' ?></small>
    <input name="password2" type="password" placeholder="Repite la contraseña">
    <small><?= $errores['password2'] ?? '' ?></small>
    <button>Registrarme</button>
</form>
php
<?php
declare(strict_types=1);

$resultado = null;
$error = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $peso = filter_var($_POST['peso'] ?? '', FILTER_VALIDATE_FLOAT);
    $estatura = filter_var($_POST['estatura'] ?? '', FILTER_VALIDATE_FLOAT);

    if ($peso === false || $estatura === false || $peso <= 0 || $estatura <= 0) {
        $error = 'Escribe un peso y una estatura válidos (números positivos).';
    } else {
        $imc = $peso / ($estatura ** 2);
        $clasificacion = match (true) {
            $imc < 18.5 => 'Bajo peso',
            $imc < 25   => 'Normal',
            $imc < 30   => 'Sobrepeso',
            default     => 'Obesidad',
        };
        $resultado = sprintf('Tu IMC es %.1f (%s)', $imc, $clasificacion);
    }
}
?>
<form method="post">
    <input name="peso" placeholder="Peso (kg)" inputmode="decimal">
    <input name="estatura" placeholder="Estatura (m)" inputmode="decimal">
    <button>Calcular</button>
</form>
<p><?= htmlspecialchars($error ?? $resultado ?? '') ?></p>
  1. Añade este brazo al match (necesita acceso a $datos, que ya está en el ámbito de la función):
php
'confirmado' => $valor !== trim((string) ($datos[$campo . '_confirmacion'] ?? ''))
    ? 'La confirmación no coincide.'
    : null,

Uso: 'password' => 'requerido|min:8|confirmado'.

Resumen ​

  • GET para consultar (búsquedas, filtros); POST para cualquier acción que cambie datos.
  • Los datos llegan en $_GET y $_POST según el atributo name de cada campo.
  • La validación del navegador es comodidad; la del servidor es obligatoria.
  • Normaliza, valida, junta errores por campo y repinta los valores escapados.
  • filter_var valida correos, enteros, URLs y booleanos.
  • Usa listas blancas para opciones (select, radio, checkbox).
  • Aplica PRG: tras un POST exitoso, redirige con header('Location: ...') y exit.

Academia Web — aprende, copia y construye.