PHP básico

Formularios: recibir y validar en el servidor

Lección 5 de 8 · 16 min

El primer dato que no escribiste tú

Hasta ahora los datos los ponías tú dentro del archivo. A partir de esta lección los pone alguien de fuera: el visitante que llena un formulario de contacto, de pedido o de registro.

Eso cambia dos cosas. La primera, cómo llega el dato hasta tu código. La segunda, más importante: ese dato puede ser cualquier cosa, y tu programa tiene que estar preparado para que lo sea.

contacto.html: el formulario
<form action="procesar.php" method="post">
    <input type="text"  name="nombre">
    <input type="email" name="correo">
    <textarea name="mensaje"></textarea>
    <button type="submit">Enviar</button>
</form>

Dos atributos mandan aquí. action dice a qué archivo se le entregan los datos, y method dice cómo viajan.

Y hay un tercero que decide todo lo demás: name. Un campo sin name no llega al servidor. Es la causa número uno de «no me llega el dato», y no da ningún error.

GET y POST: cuándo cada uno

GET

Los datos viajan en la dirección: buscar.php?q=teclado. Se ven, se pueden guardar en favoritos y compartir.

Es lo correcto para una búsqueda o un filtro: la dirección describe lo que estás viendo.

POST

Los datos viajan dentro de la petición, no en la dirección. No se ven en la barra ni quedan en el historial.

Es lo correcto para lo que cambia algo: registrar, comprar, enviar un mensaje, cambiar una contraseña.

La regla para decidir

Si volver a cargar la página repitiendo la acción sería un problema -un pedido duplicado, un mensaje enviado dos veces-, es POST.

Y ojo: POST no es seguridad. Solo es menos visible. Lo que protege de verdad es HTTPS, y aun así hay que validar.

procesar.php: recibir lo que llegó
<?php
// De un formulario TODO llega como texto, incluso los números.
$nombre  = trim($_POST['nombre'] ?? '');
$correo  = trim($_POST['correo'] ?? '');
$mensaje = trim($_POST['mensaje'] ?? '');

echo 'Recibido de: ' . $nombre;

$_POST es un arreglo asociativo -de los de la lección 4- donde la clave es el name de cada campo. Con method="get" sería $_GET, igual de sencillo.

Las dos precauciones de esa línea son fijas y conviene copiarlas siempre: ?? '' evita el aviso si el campo no vino, y trim() quita los espacios que el visitante dejó sin querer al copiar y pegar.

Ahora la parte que de verdad importa

El formulario de arriba tiene type="email". El navegador, con eso, se niega a enviar si el texto no parece un correo. Perfecto para el usuario.

Y no vale absolutamente nada como control. Porque nadie está obligado a usar tu formulario para enviarte datos:

La misma petición, sin pasar por el formulario
curl -X POST https://ejemplo.com/procesar.php \
     -d "nombre=cualquiera" \
     -d "correo=esto-no-es-un-correo" \
     -d "mensaje=hola"

Esa orden cabe en un renglón, no necesita tu página y le llega a tu procesar.php exactamente igual que si viniera del formulario. El type="email", el required y todo el JavaScript de validación no han existido.

Escríbelo así: lo del navegador es una sugerencia; lo que decide es el servidor. No es una recomendación de estilo, es la diferencia entre un sistema que aguanta y uno al que le venden un producto de 22.000 pesos por 100.

La validación de verdad, en el servidor
<?php
$errores = [];

$nombre = trim($_POST['nombre'] ?? '');
$correo = trim($_POST['correo'] ?? '');
$copias = (int) ($_POST['copias'] ?? 0);

if ($nombre === '') {
    $errores[] = 'El nombre es obligatorio.';
}

if (mb_strlen($nombre) > 80) {
    $errores[] = 'El nombre es demasiado largo.';
}

if (!filter_var($correo, FILTER_VALIDATE_EMAIL)) {
    $errores[] = 'El correo no tiene un formato válido.';
}

// La regla de negocio: entre 1 y 50. Aquí, no solo en el <input min max>.
if ($copias < 1 || $copias > 50) {
    $errores[] = 'La cantidad debe estar entre 1 y 50.';
}

if ($errores === []) {
    echo 'Todo correcto, guardando...';
} else {
    foreach ($errores as $error) {
        echo '<p>' . htmlspecialchars($error) . '</p>';
    }
}

Por qué está escrito así

  1. Se juntan los errores, no se corta en el primero

    Se van acumulando en $errores y se muestran todos juntos. Corregir un fallo por recarga es la forma más rápida de que alguien abandone un formulario.

  2. El número se convierte antes de compararlo

    (int) convierte el texto a número entero. Sin eso estarías comparando el texto '8' con el número 8, que con === no coinciden -lección 3-.

  3. El correo lo valida PHP, no una expresión copiada

    filter_var() con FILTER_VALIDATE_EMAIL ya viene hecho y bien hecho. Comprueba que tenga forma de correo, que no es lo mismo que comprobar que exista: eso solo lo demuestra enviarle un mensaje de confirmación.

  4. La regla de negocio está aquí dentro

    El tope de 50 no vive en el <input max="50">: vive en esta comprobación. El atributo del formulario es la comodidad; esta línea es la regla.

Y al mostrarlo: nunca tal cual

Falta el otro lado del mismo problema. Si el visitante escribe <script> en el campo del nombre y tú lo imprimes en la página, ese código se ejecuta en el navegador de quien lo lea. Se llama XSS y es de los agujeros más comunes que hay.

La defensa es una función y usarla siempre: htmlspecialchars() convierte los caracteres con significado en HTML -<, >, &, las comillas- en su versión inofensiva.

Lo que entra frente a lo que sale
<?php
$nombre = '<script>robar()</script>';

echo $nombre;                      // se ejecuta: mal
echo htmlspecialchars($nombre);    // se ve el texto: bien
Resultado
&lt;script&gt;robar()&lt;/script&gt;

Desde PHP 8.1 no hace falta pasarle nada más: htmlspecialchars() ya escapa por defecto también la comilla simple. En ejemplos de internet verás htmlspecialchars($x, ENT_QUOTES), que era obligatorio antes y hoy es redundante -aunque no molesta-.

La regla en una frase: se valida al entrar y se escapa al salir. Las dos cosas, siempre, sin excepciones que «esto es interno».

Detalles que se preguntan enseguida

¿Y si vuelvo a cargar y se envía dos veces?

Le pasa a todo el mundo: F5 después de un POST reenvía el formulario y duplica el pedido. La solución estándar es redirigir después de guardar, para que la página que queda cargada sea otra:

header('Location: gracias.php'); exit;

Ese exit no es opcional: sin él, el código de abajo se sigue ejecutando.

¿Puedo usar $_REQUEST y ahorrarme la diferencia?

Existe y mezcla GET, POST y cookies. Precisamente por eso no se usa: un dato que esperabas por POST podría llegar por la dirección, y pierdes el control de por dónde entra qué.

Sé explícito: $_POST o $_GET, el que corresponda.

El mismo archivo para mostrar y procesar

Es muy común y se hace preguntando por el método de la petición:

if ($_SERVER['REQUEST_METHOD'] === 'POST') { ... }

Si es POST se procesa; si no, se pinta el formulario vacío.

Subida de archivos

Llega en $_FILES y necesita enctype="multipart/form-data" en el formulario. Tiene sus propias reglas -comprobar el tipo real y no fiarse de la extensión, y guardar el archivo fuera de la carpeta pública- y da para una lección entera.

Ese último punto no es un detalle: un archivo subido dentro de la carpeta pública se descarga con solo saber la dirección, aunque tu aplicación pida contraseña.

Compruébalo tú mismo

Tu formulario tiene required y type="email". ¿Qué protege eso?

Un campo del formulario no llega nunca a $_POST. ¿Qué revisas primero?

El nombre que escribió el visitante se va a mostrar en la página. ¿Qué haces con él?

Lo que sigue

Ya recibes datos y no te fías de ellos. Ahora el problema es otro: tu sitio son varias páginas y estás copiando la cabecera y el menú en todas. En la siguiente lección se escriben una sola vez.

Siguiente: Armar el sitio por partes

Continuar ← Arreglos y funciones