Saltar a contenido

Lab 06 — Formularios, validación, PRG y flash messages

Campo Valor
Módulo Implantación de Aplicaciones Web (IMW)
Ciclo Administración de Sistemas Informáticos en Red (ASIR)
Unidad UT4 — PHP en servidor
Criterios de evaluación (CE) f (formularios y entrada de datos), g (procesamiento de datos en el servidor)
Requisitos Labs 01, 02, 03, 04 y 05 completados
Agrupación Individual
Herramientas Navegador + editor de texto + terminal
Apuntes https://ichigar.codeberg.page/imw/recursos/ut4_php_06_formularios_y_validacion/
Tiempo estimado 120–150 minutos

Antes de empezar

Entorno de trabajo

Este lab continúa el proyecto ~/gestor/ que vienes ampliando desde el Lab 01. Al final del Lab 05, la estructura quedó así:

gestor/
├── public/
│   ├── index.php, info_servidor.php          (Lab 01)
│   ├── variables.php, resumen.php            (Lab 02)
│   ├── arrays.php, listado.php, catalogo.php (Lab 03)
│   ├── estado.php                            (Lab 04)
│   └── css/estilos.css                       (Lab 05)
├── templates/
│   ├── _header.php                           (Lab 05)
│   └── _footer.php                           (Lab 05)
└── src/

Si http://gestor.local/listado.php no se ve con la cabecera azul, el menú y el pie en el año actual, revisa el Lab 05 antes de continuar: vamos a partir del listado.php con plantillas.

Qué vamos a hacer

Hasta ahora todas tus páginas solo mostraban información: arrays, condiciones, plantillas. Hoy el navegador empezará a enviar datos al servidor. Vamos a:

  • Añadir un formulario de búsqueda GET a listado.php para filtrar incidencias por título desde la URL.
  • Crear public/nueva.php con un formulario POST para registrar incidencias nuevas.
  • Implementar validación en el servidor (campos requeridos, longitud, valor en una lista cerrada) que muestre los errores junto a cada campo y conserve lo que el usuario ya había escrito.
  • Aplicar el patrón PRG (Post-Redirect-Get) para que recargar la página no reenvíe el formulario.
  • Mostrar un flash message de confirmación en listado.php cuando se acabe de crear una incidencia.
  • En el paso "aplica lo aprendido": añadir un selector de filtro por estado al listado.php.

GET y POST en este lab

Hoy verás los dos métodos HTTP que usan los formularios. El Paso 1 trabaja con GET (buscar y filtrar, datos en la URL); a partir del Paso 2 entra POST (crear datos, en el cuerpo de la petición). La regla de cuándo usar cada uno la repaso en cada paso.


Cómo se entrega este lab

Al terminar, empaqueta tu trabajo en un ZIP con esta estructura exacta:

apellido_nombre_lab06/
├── gestor/
│   ├── public/
│   │   ├── listado.php
│   │   └── nueva.php
│   └── templates/
│       └── _header.php
├── capturas/
│   ├── paso1_busqueda_get.png
│   ├── paso2_var_dump_post.png
│   ├── paso3_validacion_errores.png
│   ├── paso4_devtools_prg.png
│   ├── paso5_flash_exito.png
│   └── paso6_filtro_estado.png
└── RESPUESTAS.md

Nombres prescritos

Los nombres de los ficheros y carpetas son obligatorios y exactos. No uses mayúsculas, espacios ni acentos donde no los haya. El script de corrección busca estos nombres concretos: si los cambias, tu entrega aparecerá como incompleta.

Solo los ficheros que toca este lab

En el ZIP solo se incluyen los ficheros nuevos o modificados en este lab: listado.php actualizado (Pasos 1, 5 y 6), nueva.php nuevo (Pasos 2, 3, 4 y 5) y _header.php con session_start() añadido (Paso 5). El resto de tu gestor/ (incluidos _footer.php, estilos.css, estado.php, etc.) se queda en tu servidor y no se entrega.

Cómo preparar la entrega:

  1. Crea una carpeta en tu equipo llamada apellido_nombre_lab06 (sustituye por tu apellido y nombre reales, en minúsculas y sin espacios ni acentos).
  2. Dentro, reproduce la estructura de arriba con los tres ficheros PHP en sus rutas exactas y la subcarpeta capturas/ con los seis PNG renombrados como se indica en cada paso.
  3. Añade un fichero RESPUESTAS.md en la raíz con una sección ## Paso N por cada pregunta del lab (la plantilla está en el anexo).
  4. Comprime la carpeta completa:
    • Windows: clic derecho sobre la carpeta → Enviar aCarpeta comprimida (en zip). El archivo resultante debe llamarse apellido_nombre_lab06.zip.
    • Linux/macOS: zip -r apellido_nombre_lab06.zip apellido_nombre_lab06/.
  5. Abre el ZIP y comprueba que al descomprimirlo aparece una sola carpeta raíz apellido_nombre_lab06/.

Comprueba la estructura del ZIP antes de subirlo

En Windows es habitual comprimir los ficheros de dentro de la carpeta en lugar de la carpeta completa. Eso genera un ZIP sin carpeta raíz, y el script de corrección lo rechaza. Comprime la carpeta apellido_nombre_lab06/, no su contenido.


Paso 1 — Búsqueda con GET en listado.php

Conceptos

  • Un formulario HTML con method="get" envía sus campos como querystring en la URL: listado.php?busqueda=VPN. El servidor los lee en $_GET.
  • GET es apropiado para acciones que no modifican datos: buscar, filtrar, navegar. La URL con los parámetros se puede compartir o marcar como favorito.
  • Cuando un mismo fichero sirve la página vacía y la página con resultados, distinguimos los dos casos según si $_GET['busqueda'] viene con valor o no.
  • value="<?= htmlspecialchars($busqueda) ?>" en el <input> repuebla el campo de búsqueda con lo que el usuario escribió: así no tiene que volver a teclearlo si quiere refinar la búsqueda.
  • Funciones nuevas de PHP que aparecen en este paso:
    • trim($cadena) devuelve la cadena sin espacios al principio ni al final. Sin él, una búsqueda con un espacio accidental ('VPN ') se trataría como distinta de 'VPN'.
    • stripos($paja, $aguja) busca la subcadena $aguja dentro de $paja sin distinguir mayúsculas y minúsculas (la "i" del nombre viene de insensitive). Devuelve la posición donde la encuentra (un número) o false si no aparece. Por eso comparamos con !== false y no con > 0: la posición 0 (encontrado al principio) es un valor válido.

Objetivo. Añadir un formulario de búsqueda en listado.php que filtre las incidencias por título. La URL final debe quedar tipo http://gestor.local/listado.php?busqueda=VPN.

Tarea. Reescribe ~/gestor/public/listado.php para que quede así (mantén el array $incidencias que ya tenías; en el lab anterior eran 3, aquí lo ampliamos a 5 para que la búsqueda tenga algo que filtrar):

<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');

$titulo_pagina = 'Listado de incidencias';
$pagina_activa = 'listado';

// 1. Leer el parámetro de búsqueda (cadena vacía si no se envía).
$busqueda = trim($_GET['busqueda'] ?? '');

$todas = [
    ['id' => 1, 'titulo' => 'Servidor web caído',  'prioridad' => 'alta',  'estado' => 'abierta'],
    ['id' => 2, 'titulo' => 'Impresora sin tóner', 'prioridad' => 'baja',  'estado' => 'resuelta'],
    ['id' => 3, 'titulo' => 'VPN no conecta',      'prioridad' => 'media', 'estado' => 'abierta'],
    ['id' => 4, 'titulo' => 'PC sin sonido',       'prioridad' => 'baja',  'estado' => 'resuelta'],
    ['id' => 5, 'titulo' => 'Backup no se ejecuta','prioridad' => 'alta',  'estado' => 'abierta'],
];

// 2. Si hay búsqueda, filtramos con un foreach + if; si no, mostramos todo.
if ($busqueda !== '') {
    $incidencias = [];
    foreach ($todas as $inc) {
        if (stripos($inc['titulo'], $busqueda) !== false) {
            $incidencias[] = $inc;
        }
    }
} else {
    $incidencias = $todas;
}

require __DIR__ . '/../templates/_header.php';
?>

<form method="get" action="/listado.php" style="margin-bottom: 1rem;">
  <input type="text" name="busqueda"
         value="<?= htmlspecialchars($busqueda) ?>"
         placeholder="Buscar por título...">
  <button type="submit">Buscar</button>
  <?php if ($busqueda !== ''): ?>
    <a href="/listado.php">Limpiar</a>
  <?php endif ?>
</form>

<?php if ($busqueda !== ''): ?>
  <p>Resultados para "<strong><?= htmlspecialchars($busqueda) ?></strong>":
     <?= count($incidencias) ?> incidencia(s).</p>
<?php else: ?>
  <p><?= count($incidencias) ?> incidencias registradas.</p>
<?php endif ?>

<table>
  <tr>
    <th>ID</th>
    <th>Título</th>
    <th>Prioridad</th>
    <th>Estado</th>
  </tr>
  <?php foreach ($incidencias as $inc): ?>
    <tr>
      <td><?= $inc['id'] ?></td>
      <td><?= htmlspecialchars($inc['titulo']) ?></td>
      <td><?= htmlspecialchars($inc['prioridad']) ?></td>
      <td><?= htmlspecialchars($inc['estado']) ?></td>
    </tr>
  <?php endforeach ?>
</table>

<?php require __DIR__ . '/../templates/_footer.php' ?>

El patrón "construyo un array vacío, recorro con foreach y meto con $incidencias[] = ... solo lo que cumple la condición" es el mismo que ya usaste en A3 al construir listas. La condición aquí es la de stripos(), que ya tienes glosada arriba.

Recarga http://gestor.local/listado.php y prueba:

  • Busca VPN → la URL pasa a listado.php?busqueda=VPN y la tabla deja una sola fila.
  • Busca serv en minúsculas → encuentra "Servidor web caído". Aunque la 'S' del título es mayúscula y tu búsqueda está en minúsculas, aparece igual: ahí ves que stripos() ignora mayúsculas/minúsculas.
  • Busca xxx → la tabla queda vacía y el contador dice "0 incidencia(s)".
  • Pulsa Limpiar → la URL vuelve a listado.php (sin parámetro) y reaparecen las 5.

Responde en RESPUESTAS.md, sección ## Paso 1.

  1. Cuando buscas "VPN", copia la URL completa que aparece en la barra de direcciones del navegador, tal cual (incluyendo http:// y todo).
  2. ¿Por qué es importante el value="<?= htmlspecialchars($busqueda) ?>" del <input>? ¿Qué pasaría si lo quitaras y buscaras "VPN"?

Captura requerida. Visita http://gestor.local/listado.php?busqueda=VPN y guarda una captura en capturas/paso1_busqueda_get.png. Debe verse:

  • La URL completa con ?busqueda=VPN en la barra de direcciones.
  • El campo de búsqueda con "VPN" dentro.
  • La tabla mostrando solo la incidencia "VPN no conecta".
  • El enlace "Limpiar" visible.

Verificación antes de pasar al Paso 2.

  • listado.php lee $_GET['busqueda'] con trim() y ?? ''.
  • El formulario tiene method="get" y un único campo name="busqueda".
  • El campo conserva el texto buscado con htmlspecialchars() en el value.
  • La búsqueda es case-insensitive (encuentra "VPN" buscando "vpn").
  • El enlace "Limpiar" solo aparece cuando hay una búsqueda activa.
  • php -l ~/gestor/public/listado.php no da errores.
  • He guardado capturas/paso1_busqueda_get.png.

Paso 2 — Crear nueva.php con un formulario POST

Conceptos

  • Con method="post", los datos del formulario viajan en el cuerpo de la petición HTTP, no en la URL. PHP los pone en $_POST.
  • POST se usa para acciones que modifican datos: crear, editar, borrar. La URL no cambia, así que el formulario no se puede guardar como favorito ni compartir.
  • Antes de validar nada, conviene ver qué llega. var_dump($_POST) imprime el contenido del array tal cual lo recibe PHP: nombres de campo, valores, tipos. Es una herramienta de diagnóstico, no algo que se quede en el código final.
  • $_SERVER['REQUEST_METHOD'] vale 'GET' o 'POST'. Lo usamos para distinguir si la página se está cargando vacía (GET) o si el usuario acaba de enviar el formulario (POST).
  • exit; termina el script PHP ahí mismo: no se ejecuta nada del código que va después, ni se pinta nada de HTML adicional. Aquí lo usamos para parar tras imprimir el var_dump; en el Paso 4 lo veremos en su segundo uso típico (justo después de header('Location: ...')).

Etiquetas HTML nuevas del formulario

Las páginas anteriores eran solo de salida. Hoy aparecen por primera vez las etiquetas que el navegador usa para enviar datos:

Etiqueta Para qué sirve Detalle clave
<form action="..." method="..."> Contenedor del formulario. action es la URL a la que se envían los datos; method es get o post.
<label for="id">Texto</label> Etiqueta de un campo. El for debe coincidir con el id del campo, así el clic en la etiqueta enfoca el campo.
<input type="text" name="..." id="..."> Campo de texto de una línea. El name es la clave que aparecerá en $_POST. El id es para el <label>.
<select name="..."><option value="..."></option>...</select> Lista desplegable. El valor enviado es el value del <option> elegido, no el texto visible.
<textarea name="..." rows="..."></textarea> Campo de texto multilínea. Su valor no va en value, va entre las etiquetas: <textarea>aquí va el valor</textarea>.
<button type="submit">Texto</button> Botón de envío. Al pulsarlo, el navegador envía el formulario padre.

Objetivo. Crear public/nueva.php con un formulario POST de tres campos (título, prioridad y descripción) y comprobar qué llega a $_POST cuando el formulario se envía.

Tarea. Crea ~/gestor/public/nueva.php con este contenido:

<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');

$titulo_pagina = 'Nueva incidencia';
$pagina_activa = 'nueva';

// Diagnóstico: si llega un POST, vemos qué contiene $_POST y salimos.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    echo '<pre>';
    var_dump($_POST);
    echo '</pre>';
    exit;
}

require __DIR__ . '/../templates/_header.php';
?>

<form method="post" action="/nueva.php">
  <div style="margin-bottom: 1rem;">
    <label for="titulo"><strong>Título *</strong></label><br>
    <input type="text" id="titulo" name="titulo"
           style="width: 100%; padding: 6px;">
  </div>

  <div style="margin-bottom: 1rem;">
    <label for="prioridad"><strong>Prioridad *</strong></label><br>
    <select id="prioridad" name="prioridad" style="padding: 6px;">
      <option value="">-- Selecciona --</option>
      <option value="baja">Baja</option>
      <option value="media">Media</option>
      <option value="alta">Alta</option>
    </select>
  </div>

  <div style="margin-bottom: 1rem;">
    <label for="descripcion">Descripción</label><br>
    <textarea id="descripcion" name="descripcion"
              rows="4" style="width: 100%; padding: 6px;"></textarea>
  </div>

  <button type="submit">Crear incidencia</button>
  <a href="/listado.php" style="margin-left: 1rem;">Cancelar</a>
</form>

<?php require __DIR__ . '/../templates/_footer.php' ?>

Recarga http://gestor.local/nueva.php: debe aparecer el formulario con cabecera, los tres campos y el botón. Rellena el título con un texto cualquiera, elige una prioridad, escribe algo en la descripción y pulsa Crear incidencia.

Lo que verás no es el listado: aún no hemos puesto ninguna lógica detrás. Verás la página de diagnóstico con el contenido de $_POST impreso con var_dump(). Por ejemplo:

array(3) {
  ["titulo"]=>
  string(11) "Prueba VPN"
  ["prioridad"]=>
  string(4) "alta"
  ["descripcion"]=>
  string(0) ""
}

Es exactamente lo que PHP recibe: tres entradas con los nombres que pusiste en los name="..." del HTML, y los valores que el usuario escribió o seleccionó.

Cuando termines de probar, borra el bloque de diagnóstico entero. En concreto, elimina las 7 líneas que van desde el comentario // Diagnóstico: ... hasta el } que cierra el if, ambas inclusive (de la línea 8 a la 14 del bloque que tecleaste). El require __DIR__ . '/../templates/_header.php'; de abajo se queda. Lo recuperaremos en el Paso 3 con validación de verdad: este bloque solo servía para que vieras qué llega en $_POST.

Responde en RESPUESTAS.md, sección ## Paso 2.

Envía el formulario dejando vacíos el textarea de descripción y sin seleccionar nada en el select de prioridad. Mira la salida de var_dump($_POST):

  1. ¿Aparece la clave "descripcion" en el array? Si aparece, ¿qué valor tiene?
  2. ¿Y la clave "prioridad" cuando dejas la opción -- Selecciona --? ¿Qué valor tiene?
  3. Has visto que tras enviar el formulario, $_POST['titulo'], $_POST['prioridad'] y $_POST['descripcion'] existen siempre, incluso vacíos. Ahora piensa en el escenario contrario: la primera vez que el usuario abre http://gestor.local/nueva.php (todavía no ha enviado nada, es una petición GET). ¿Crees que $_POST['titulo'] existirá ya en ese momento, o no? ¿Qué pasaría si tu código intentara leerlo directamente sin precaución?

Captura requerida. Rellena los tres campos y envía el formulario. Cuando aparezca la página de diagnóstico, guarda una captura en capturas/paso2_var_dump_post.png. Debe verse:

  • La URL http://gestor.local/nueva.php en la barra.
  • La salida de var_dump($_POST) con las tres claves (titulo, prioridad, descripcion) y sus valores.

Verificación antes de pasar al Paso 3.

  • ~/gestor/public/nueva.php existe y se ve correctamente con cabecera y pie.
  • El formulario tiene method="post" y tres campos: titulo, prioridad y descripcion.
  • Al enviar el formulario aparece la salida de var_dump($_POST).
  • He eliminado el bloque if ($_SERVER['REQUEST_METHOD'] === 'POST') { ... exit; } tras la prueba.
  • php -l ~/gestor/public/nueva.php no da errores.
  • He guardado capturas/paso2_var_dump_post.png.

Paso 3 — Validación en el servidor y errores junto a cada campo

Conceptos

  • La validación del navegador (required, maxlength) se puede saltar abriendo las DevTools y quitando el atributo, o enviando la petición con curl. La única validación que no se puede saltar es la del servidor.
  • El flujo estándar es recoger → sanear → validar → procesar o re-mostrar. Saneamos con trim() para quitar espacios sobrantes. Si la validación falla, re-mostramos el formulario con los valores escritos y los mensajes de error junto a cada campo.
  • Acumulamos errores en un array $errores indexado por nombre de campo: $errores['titulo'] = 'El título es obligatorio.';. Después, en el HTML, comprobamos con isset($errores['titulo']) si hay que pintar el mensaje rojo bajo ese campo. isset() lo viste en A5 aplicado a variables simples (isset($titulo_pagina)); funciona igual con claves de array.
  • in_array($valor, $lista, true) con el true activa la comparación estricta (===). Sin él, PHP convierte tipos automáticamente y in_array('0', ['baja','media','alta']) daría sorpresas. Para validar que un valor está en una lista cerrada usamos siempre true.

Sintaxis nueva: el operador ternario ? :

En el HTML del formulario vas a usar una forma corta del if/else llamada operador ternario:

$color = isset($errores['titulo']) ? 'rojo' : 'normal';

Se lee: "si la condición isset(...) es cierta, el valor es 'rojo'; si no, es 'normal'". Equivale al if/else clásico que viste en A4, pero cabe en una línea. Lo usaremos sobre todo dentro de atributos HTML para pintar (o no) un trozo de estilo según haya error en un campo:

style="<?= isset($errores['titulo']) ? 'border: 2px solid red;' : '' ?>"

Mala práctica: imprimir $_POST['titulo'] directo en el value del input

value="<?= $_POST['titulo'] ?? '' ?>" parece más corto, pero el usuario puede haber tecleado caracteres especiales (incluso <script>) y los pegarías literalmente en el HTML. Pasa siempre la salida por htmlspecialchars().

Paso largo: hazlo en dos pasadas

Este es el paso con más código del lab: vas a reescribir dos bloques distintos de nueva.php (cabecera PHP en la Tarea 1, formulario HTML completo en la Tarea 2) y aparecen 3 conceptos nuevos a la vez ($val, $errores, operador ternario en atributos HTML). Hazlo despacio. Guarda y prueba con php -l después de la Tarea 1 antes de pasar a la Tarea 2 — así si rompes algo, sabes en cuál de las dos tareas fue.

Objetivo. Implementar validación completa en nueva.php que detecte campos vacíos, títulos demasiado largos y prioridades inválidas, mostrando los errores en rojo junto a cada campo y conservando lo que el usuario había escrito.

Tarea 1 — Reescribir el bloque PHP de cabecera de nueva.php. Sustituye la cabecera PHP (lo que hay antes del require de _header.php) por esto:

<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');

$titulo_pagina = 'Nueva incidencia';
$pagina_activa = 'nueva';

// Valores iniciales del formulario (vacíos en el GET).
$val = ['titulo' => '', 'prioridad' => '', 'descripcion' => ''];
$errores = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // 1. Recoger y sanear.
    $val['titulo']      = trim($_POST['titulo']      ?? '');
    $val['prioridad']   = trim($_POST['prioridad']   ?? '');
    $val['descripcion'] = trim($_POST['descripcion'] ?? '');

    // 2. Validar.
    if ($val['titulo'] === '') {
        $errores['titulo'] = 'El título es obligatorio.';
    } elseif (strlen($val['titulo']) > 80) {
        $errores['titulo'] = 'El título no puede superar 80 caracteres.';
    }

    if (!in_array($val['prioridad'], ['baja', 'media', 'alta'], true)) {
        $errores['prioridad'] = 'Selecciona una prioridad válida.';
    }

    // 3. Si no hay errores, de momento solo lo confirmamos en pantalla.
    if (empty($errores)) {
        echo '<p style="color: green;">Datos válidos. Título: '
           . htmlspecialchars($val['titulo']) . '</p>';
        exit;
    }
    // Si hay errores, seguimos y la página vuelve a pintar el formulario.
}

require __DIR__ . '/../templates/_header.php';
?>

Comprobación intermedia tras la Tarea 1. Antes de tocar el HTML, comprueba que el PHP solo no rompe nada: ejecuta php -l ~/gestor/public/nueva.php (no debe dar errores) y recarga la página en el navegador. Como aún no has tocado el <form> del Paso 2, el formulario se ve igual que antes y al enviarlo con datos válidos verás el mensaje verde "Datos válidos. Título: ..." (sin estilo de errores, porque el HTML del Paso 2 no los pinta todavía). Eso es lo esperado: en la Tarea 2 le toca al HTML.

Tarea 2 — Reescribir el HTML del formulario. Sustituye el <form> que tenías por este, que conserva los valores y muestra los errores junto a cada campo:

<form method="post" action="/nueva.php">
  <div style="margin-bottom: 1rem;">
    <label for="titulo"><strong>Título *</strong></label><br>
    <input type="text" id="titulo" name="titulo"
           value="<?= htmlspecialchars($val['titulo']) ?>"
           style="width: 100%; padding: 6px;
                  <?= isset($errores['titulo']) ? 'border: 2px solid red;' : '' ?>">
    <?php if (isset($errores['titulo'])): ?>
      <p style="color: red; margin: 4px 0 0;"><?= htmlspecialchars($errores['titulo']) ?></p>
    <?php endif ?>
  </div>

  <div style="margin-bottom: 1rem;">
    <label for="prioridad"><strong>Prioridad *</strong></label><br>
    <select id="prioridad" name="prioridad" style="padding: 6px;">
      <option value="">-- Selecciona --</option>
      <?php foreach (['baja', 'media', 'alta'] as $op): ?>
        <option value="<?= $op ?>" <?= $val['prioridad'] === $op ? 'selected' : '' ?>>
          <?= ucfirst($op) ?>
        </option>
      <?php endforeach ?>
    </select>
    <?php if (isset($errores['prioridad'])): ?>
      <p style="color: red; margin: 4px 0 0;"><?= htmlspecialchars($errores['prioridad']) ?></p>
    <?php endif ?>
  </div>

  <div style="margin-bottom: 1rem;">
    <label for="descripcion">Descripción</label><br>
    <textarea id="descripcion" name="descripcion"
              rows="4" style="width: 100%; padding: 6px;"><?= htmlspecialchars($val['descripcion']) ?></textarea>
  </div>

  <button type="submit">Crear incidencia</button>
  <a href="/listado.php" style="margin-left: 1rem;">Cancelar</a>
</form>

<?php require __DIR__ . '/../templates/_footer.php' ?>

Fíjate en dos detalles:

  • En el <textarea>, el valor va entre las etiquetas (no en un atributo value): <textarea ...><?= htmlspecialchars(...) ?></textarea>. Es así como funcionan los textareas en HTML.
  • El <select> mantiene la prioridad elegida poniendo el atributo selected en el <option> cuya $op coincide con $val['prioridad'].

Ahora prueba:

  • Envía el formulario completamente vacío: deben aparecer dos errores rojos ("El título es obligatorio." y "Selecciona una prioridad válida.") junto a sus campos. El textarea sigue vacío, sin error.
  • Escribe un título de más de 80 caracteres (puedes pegar aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa repetido), elige prioridad y envía: aparece el error de longitud, pero la prioridad seleccionada se mantiene marcada.
  • Rellena el título, elige prioridad y deja la descripción vacía o llena: ahora sí entras al if (empty($errores)) y ves el mensaje verde "Datos válidos. Título: ...". Más adelante reemplazaremos ese echo por un guardado real, pero la validación ya funciona.
Pista si los errores aparecen pero el texto del input se borra

Es el síntoma de no haber puesto value="<?= htmlspecialchars($val['titulo']) ?>" en el <input>. PHP recoge el valor en $val, pero si el HTML no lo vuelca de nuevo en el campo, el navegador pinta el input vacío. Comprueba que cada campo del formulario consulta su entrada correspondiente de $val.

Responde en RESPUESTAS.md, sección ## Paso 3.

  1. ¿Por qué validamos en el servidor si el navegador ya ofrece required, maxlength y similares? Da un ejemplo concreto en el que la validación del navegador no sirva de nada.
  2. ¿Por qué usamos in_array($val['prioridad'], ['baja', 'media', 'alta'], true) con el tercer parámetro true? ¿Qué riesgo tendría omitirlo?

Captura requerida. Envía el formulario con el título relleno (por ejemplo, "Servidor lento") pero sin seleccionar prioridad (deja la opción "-- Selecciona --"). Guarda la captura en capturas/paso3_validacion_errores.png. Debe verse:

  • El formulario re-pintado con el título "Servidor lento" todavía visible en el input.
  • El campo de prioridad con -- Selecciona -- y el mensaje rojo "Selecciona una prioridad válida." debajo.
  • (Opcional) Ningún error junto al título, porque ese campo sí fue válido.

Verificación antes de pasar al Paso 4.

  • La cabecera PHP de nueva.php define $val y $errores antes de cualquier salida HTML.
  • Si el formulario se envía vacío, aparecen los dos errores correspondientes en rojo junto a sus campos.
  • Si el título es válido pero la prioridad no, el título escrito se conserva en el input.
  • Si el formulario es válido, aparece "Datos válidos. Título: ..." en verde.
  • El bloque var_dump($_POST) del Paso 2 ya no existe en nueva.php.
  • php -l ~/gestor/public/nueva.php no da errores.
  • He guardado capturas/paso3_validacion_errores.png.

Paso 4 — Patrón PRG (Post-Redirect-Get)

Conceptos

  • Cuando un POST se procesa con éxito y devuelve directamente el HTML del resultado, recargar la página hace que el navegador pregunte "¿Reenviar el formulario?". Si el usuario dice que sí, la acción se repite (en una página que crea incidencias, eso son duplicados silenciosos).
  • PRG rompe el ciclo: tras procesar el POST con éxito, el servidor responde con una redirección HTTP 302 a otra URL. El navegador sigue la redirección con un GET, que sí es seguro de recargar.
  • header('Location: /listado.php') envía esa cabecera de redirección. Debe llamarse antes de cualquier salida: ni echo, ni HTML, ni require de la plantilla. Si ya salió algo, PHP avisa con "headers already sent" y la redirección falla.
  • exit; justo después es obligatorio: sin él, PHP sigue ejecutando el script aunque el navegador ya esté redirigiendo, y eso puede ejecutar dos veces lógica que solo debía correr una.

Mala práctica: olvidar exit tras header('Location: ...')

El navegador redirige al ver la cabecera, pero el servidor sigue corriendo código y ejecuta cosas que ya no debían ejecutarse. La pareja header(...) + exit; se escribe siempre junta.

Objetivo. Cambiar el bloque "datos válidos" de nueva.php para que, en lugar de imprimir un mensaje verde, redirija al listado y comprobar con las DevTools la secuencia POST → 302 → GET.

Tarea 1 — Provoca el problema antes de resolverlo. Para ver con tus ojos el problema que PRG arregla, hazlo primero sin PRG:

  1. Visita http://gestor.local/nueva.php, rellena el formulario con datos válidos (título "Prueba sin PRG", prioridad "alta") y pulsa Crear incidencia. Verás el mensaje verde "Datos válidos. Título: Prueba sin PRG" que dejaste en el Paso 3.
  2. Pulsa F5 para recargar la página que está mostrando ese mensaje verde.
  3. El navegador te pregunta algo tipo "¿Quieres reenviar el formulario? Para reproducir esta página, el navegador tiene que reenviar los datos previamente enviados." (el texto exacto varía entre navegadores). Si dices que sí, el POST se repite: en una página real que guardara la incidencia, se crearía un duplicado sin que tú hubieras hecho nada raro.

Ese es el problema. Vuelve a la página nueva.php (no aceptes el reenvío) y a continuación lo arreglamos con PRG.

Tarea 2 — Implementa PRG. En ~/gestor/public/nueva.php, localiza el bloque que en el Paso 3 quedó así:

1
2
3
4
5
if (empty($errores)) {
    echo '<p style="color: green;">Datos válidos. Título: '
       . htmlspecialchars($val['titulo']) . '</p>';
    exit;
}

Sustitúyelo entero por:

1
2
3
4
if (empty($errores)) {
    header('Location: /listado.php');
    exit;
}

Guarda y prueba el flujo completo desde las DevTools:

  1. Abre http://gestor.local/nueva.php.
  2. Pulsa F12 para abrir las DevTools y entra en la pestaña Network (o Red).
  3. Marca la casilla Preserve log / Conservar registro (en Firefox aparece como "Persistir registros"). Está en la barra de herramientas superior de la pestaña Network, junto al filtro de tipo. Importante: márcala antes de enviar el formulario, no después; si no, la lista se borra al cambiar de URL y solo verás la petición final.
  4. Rellena el formulario con datos válidos (título "Prueba PRG", prioridad "media") y envía.
  5. Observa la lista de peticiones. Deben aparecer dos seguidas:
    • POST /nueva.php con código 302.
    • GET /listado.php con código 200.
  6. La URL del navegador termina en /listado.php, no en /nueva.php.
  7. Pulsa F5 para recargar. Ahora el navegador no pregunta nada: se limita a repetir el GET de listado.php, sin reenvío de formulario. Compara con lo que pasaba en la Tarea 1: ese era el problema, esto es la solución.
Pista si no ves las dos peticiones en Network

Algunos navegadores ocultan por defecto las redirecciones. Asegúrate de:

  • Tener marcada la casilla Preserve log / Conservar registro antes de enviar el formulario.
  • Tener seleccionado el filtro All / Todo (no solo "XHR", "Fetch", "JS"...).
  • Si aun así solo ves la petición final, mira la columna Status: si hay una fila marcada con 302 en gris, es la del POST.
Pista si recibes 'headers already sent'

Antes de seguir, asegúrate de que estás viendo de verdad ese aviso en el navegador. Si en lugar del mensaje "headers already sent" lo que ves es una página en blanco o un HTTP ERROR 500 sin texto, es que display_errors sigue apagado en php.ini y el mensaje de PHP no llega al navegador. Vuelve al Paso 5 del lab A1 — Depuración: ver los errores de PHP — y actívalo. Una vez activado, sirve para el resto de los labs.

Si ya ves el aviso, sigue aquí. PHP lo emite cuando header('Location: ...') se llama después de haber enviado algo al navegador. Comprueba que:

  • El header() está dentro del bloque PHP de cabecera de nueva.php, antes del require __DIR__ . '/../templates/_header.php';.
  • El fichero nueva.php no tiene ningún espacio en blanco ni texto antes del primer <?php. Algunos editores meten un BOM invisible al inicio del fichero: en VS Code, mira la esquina inferior derecha de la ventana; si pone UTF-8 with BOM, pulsa ahí, elige "Save with Encoding" y selecciona UTF-8 (sin BOM).

Responde en RESPUESTAS.md, sección ## Paso 4.

  1. ¿Qué código HTTP devuelve el POST /nueva.php cuando los datos son válidos? ¿Y el GET /listado.php siguiente?
  2. ¿Qué pasaría si quitaras el exit; que va justo después de header('Location: /listado.php');? Piensa en qué ejecuta el servidor después de enviar la cabecera de redirección.

Captura requerida. Repite el envío con datos válidos con las DevTools abiertas en Network y Preserve log activado, y guarda una captura en capturas/paso4_devtools_prg.png. Debe verse:

  • La pestaña Network de las DevTools con las dos peticiones visibles: POST /nueva.php con status 302, y GET /listado.php con status 200.
  • La URL del navegador en http://gestor.local/listado.php.

Verificación antes de pasar al Paso 5.

  • El bloque if (empty($errores)) de nueva.php redirige con header('Location: /listado.php') y exit;.
  • Tras enviar un formulario válido, la URL termina en /listado.php.
  • Al recargar listado.php con F5, el navegador no pregunta por reenvío.
  • En la pestaña Network aparece el POST 302 + GET 200.
  • php -l ~/gestor/public/nueva.php no da errores.
  • He guardado capturas/paso4_devtools_prg.png.

Paso 5 — Flash messages: confirmar la acción al usuario

Conceptos

  • Tras el PRG, el usuario llega al listado.php sin saber si la incidencia se creó. Necesitamos un mensaje breve que aparezca una vez y se vaya: lo que se llama un flash message.
  • Lo guardamos en $_SESSION justo antes de redirigir, y lo leemos y borramos en la página destino. Si no lo borramos, se queda pegado a todas las páginas hasta que la sesión expire.
  • $_SESSION es una superglobal que persiste entre peticiones del mismo usuario. Para activarla hay que llamar a session_start() antes de cualquier salida: antes de echo, antes de HTML suelto, antes del primer require de plantilla.
  • Regla operativa: cualquier página que vaya a leer o escribir en $_SESSION antes del require de _header.php tiene que llamar ella misma a session_start() arriba del todo. Eso es lo que va a pasar en nueva.php (escribirá el flash antes del redirect) y en listado.php (leerá el flash antes del require).

Cookies de sesión y por qué session_start() va arriba

Una cookie es un dato pequeño (clave=valor) que el servidor envía al navegador en una cabecera HTTP de la respuesta, y que el navegador devuelve automáticamente en cada petición posterior al mismo sitio.

La primera vez que llamas a session_start(), PHP:

  1. Genera un identificador único de sesión (una cadena hexadecimal aleatoria).
  2. Lo guarda en disco asociado a los datos de $_SESSION.
  3. Envía ese identificador al navegador como cookie llamada PHPSESSID.

Esa cookie va en una cabecera HTTP, igual que el header('Location: ...') del Paso 4. Y las cabeceras se envían antes del HTML. Por eso session_start() tiene que ir arriba del todo: si ya salió HTML, las cabeceras están cerradas y PHP avisa con "Cannot send session cookie - headers already sent". Es la misma regla del Paso 4 aplicada a sesiones.

session_status() y la guarda de doble inicialización

Si session_start() se llama dos veces en la misma petición, PHP emite un aviso. En la Tarea 1 vas a meter session_start() en _header.php con una guarda:

if (session_status() === PHP_SESSION_NONE) {
    session_start();
}

session_status() devuelve uno de tres valores: PHP_SESSION_NONE (sesión no iniciada), PHP_SESSION_ACTIVE (sesión iniciada) o PHP_SESSION_DISABLED (sesiones desactivadas en el servidor). Las tres son constantes de PHP (van en mayúsculas y sin $, como E_ALL del error_reporting que vienes usando desde A1).

La guarda dice: "inicia sesión solo si no estaba iniciada ya". Es útil porque en este lab nueva.php y listado.php van a llamar a session_start() antes del require (Tarea 2 y 3), y sin la guarda, el require del _header.php lo llamaría una segunda vez y daría aviso.

Objetivo. Que tras crear una incidencia con éxito aparezca un mensaje verde de confirmación en listado.php que diga "Incidencia creada correctamente.", y que ese mensaje desaparezca al recargar la página.

Tarea 1 — Añadir session_start() al inicio de _header.php. Abre ~/gestor/templates/_header.php. Justo después del <?php inicial (la primera línea), añade el arranque de sesión con la guardia:

<?php
if (session_status() === PHP_SESSION_NONE) {
    session_start();
}
// $titulo_pagina debe estar definida antes del require.
// Si no lo está, usamos un valor por defecto para evitar el aviso.
if (!isset($titulo_pagina)) {
    $titulo_pagina = 'Gestor de incidencias';
}
// ... resto del fichero igual ...

Así, cualquier página que haga require de _header.php tiene sesión disponible. Si una página la había arrancado antes (como van a hacer nueva.php y listado.php en las Tareas 2 y 3), session_status() evita la llamada doble.

Comprobación intermedia tras la Tarea 1. Recarga http://gestor.local/listado.php en el navegador: la página tiene que seguir funcionando exactamente igual que al final del Paso 4. Si ya no se ve la cabecera o sale un error, has roto _header.php y no lo verás en pasos posteriores. Corrige antes de seguir.

Tarea 2 — Guardar el flash en nueva.php antes de redirigir. En ~/gestor/public/nueva.php:

  1. Justo después del <?php inicial, antes de los error_reporting, añade session_start();. En nueva.php la sesión hay que iniciarla aquí (no esperar al _header.php) porque el flash se guarda antes del header('Location: ...'), y ese header está antes del require de la cabecera.
  2. Localiza el bloque if (empty($errores)) { header('Location: /listado.php'); exit; } del Paso 4 y sustitúyelo entero por:
1
2
3
4
5
6
7
8
if (empty($errores)) {
    $_SESSION['flash'] = [
        'tipo' => 'exito',
        'msg'  => 'Incidencia "' . $val['titulo'] . '" creada correctamente.',
    ];
    header('Location: /listado.php');
    exit;
}

La cabecera PHP de nueva.php debe empezar ahora así:

1
2
3
4
5
<?php
session_start();
error_reporting(E_ALL);
ini_set('display_errors', '1');
// ... resto igual que el Paso 3 ...

Por qué aquí concateno $val['titulo'] sin htmlspecialchars()

En el Paso 3 te insistí en pasar todo por htmlspecialchars(). Aquí estamos guardando el título en $_SESSION para usarlo luego, no estamos pintándolo todavía. En la Tarea 3 verás que el flash se pinta con <?= htmlspecialchars($flash['msg']) ?>: ahí es donde se escapa. La regla es se escapa al pintar, no al guardar: si lo escapas aquí y otra vez al pintar, los caracteres especiales se mostrarían dos veces escapados (&amp;quot; en lugar de ").

Comprobación intermedia tras la Tarea 2. Sin haber tocado aún listado.php, crea una incidencia válida desde nueva.php. El redirect a listado.php debe seguir funcionando (URL acaba en /listado.php, no aparece error). Todavía no verás el flash porque listado.php aún no lo lee. Si en cambio recibes un error tipo "Cannot send session cookie - headers already sent", has metido el session_start() en el sitio equivocado: tiene que ser la primera línea ejecutable, justo después de <?php.

Tarea 3 — Leer y pintar el flash en listado.php. En ~/gestor/public/listado.php:

  1. Justo después del <?php inicial, antes de error_reporting, añade session_start();.
  2. Justo después de la línea $pagina_activa = 'listado';, añade la lectura y borrado del flash:
1
2
3
$pagina_activa = 'listado';
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
  1. En el HTML, justo después del require de _header.php y antes del formulario de búsqueda, añade el bloque que pinta el flash si existe:
require __DIR__ . '/../templates/_header.php';
?>

<?php if ($flash): ?>
  <div style="padding: 12px 16px; margin-bottom: 1rem; border-radius: 6px;
              <?= $flash['tipo'] === 'exito'
                  ? 'background: #d5f0e0; border: 1px solid #27ae60; color: #1a5c34;'
                  : 'background: #fde8e8; border: 1px solid #c0392b; color: #7b1c1c;' ?>">
    <?= htmlspecialchars($flash['msg']) ?>
  </div>
<?php endif ?>

<form method="get" action="/listado.php" style="margin-bottom: 1rem;">
  <!-- ... formulario de búsqueda del Paso 1, sin tocar ... -->

Guarda y prueba el ciclo completo:

  1. Visita http://gestor.local/nueva.php.
  2. Crea una incidencia válida (título "Prueba flash", prioridad "alta"). Envía.
  3. El navegador acaba en http://gestor.local/listado.php con un recuadro verde que dice Incidencia "Prueba flash" creada correctamente. arriba.
  4. Recarga la página con F5. El recuadro verde desaparece: solo se muestra una vez.
Pista si el flash no aparece

Errores típicos, por orden de probabilidad:

  • session_start() no es la primera instrucción de nueva.php o listado.php: comprueba que va justo después de <?php, sin nada delante.
  • Has llamado a $_SESSION['flash'] = ... después del header('Location: ...'): el header() y exit se ejecutan antes de que llegue a guardar. El orden correcto es flash → header → exit.
  • Has guardado el flash pero no llamas a session_start() en listado.php: PHP no carga $_SESSION y la variable está vacía.

Responde en RESPUESTAS.md, sección ## Paso 5.

  1. ¿Por qué unset($_SESSION['flash']) en listado.php es importante? ¿Qué pasaría si lo quitaras y luego visitaras directamente listado.php desde el menú varias veces seguidas?
  2. ¿Por qué session_start() debe ir antes de cualquier salida HTML? Pista: piensa en qué tiene que enviar al navegador la primera vez que se inicia una sesión.

Captura requerida. Crea una incidencia válida desde nueva.php y, cuando el navegador acabe en listado.php, guarda una captura en capturas/paso5_flash_exito.png. Debe verse:

  • La URL http://gestor.local/listado.php en la barra.
  • El recuadro verde con el mensaje Incidencia "..." creada correctamente. en la parte superior.
  • El formulario de búsqueda y la tabla del listado justo debajo.

Verificación antes de pasar al Paso 6.

  • _header.php llama a session_start() con la guardia session_status() === PHP_SESSION_NONE.
  • nueva.php empieza con session_start(); justo después de <?php.
  • nueva.php guarda $_SESSION['flash'] antes del header('Location:...').
  • listado.php empieza con session_start(); y lee $_SESSION['flash'] con unset() después.
  • El flash aparece tras crear una incidencia y desaparece al recargar.
  • Los tres ficheros (_header.php, nueva.php, listado.php) pasan php -l sin errores.
  • He guardado capturas/paso5_flash_exito.png.

Paso 6 — Aplica lo aprendido: filtro por estado en listado.php

Qué es este paso

Los pasos anteriores han sido guiados: te he dado el código y tú lo has tecleado, probado y entendido. En este sexto paso no hay código de referencia completo. Lo que hay son requisitos, y tú aplicas lo aprendido sobre $_GET, <select> con selected y filtrado de arrays para resolver un problema nuevo.

El reto: añadir un selector de estado al formulario de búsqueda de listado.php que filtre las incidencias por estado (todos / abierta / resuelta), combinándose con la búsqueda por título que ya tienes.

Esto es una modificación reversible de listado.php (regla de los labs: el paso "aplica" produce material extra y desechable). Si decides borrar el selector y dejarlo solo con la búsqueda del Paso 1, la página sigue funcionando: el filtro de estado es una capa adicional encima del sistema que ya tienes.

Objetivo. Ampliar el formulario de búsqueda de listado.php con un <select> de estado, de forma que se pueda filtrar por título, por estado, o por ambos a la vez.

Tarea. Modifica ~/gestor/public/listado.php cumpliendo los requisitos siguientes.

Requisitos obligatorios de contenido:

  1. En la cabecera PHP de listado.php (donde lees $_GET['busqueda']), lee también un segundo parámetro $_GET['estado'] con valor por defecto 'todos'. Aplica trim() igual que con la búsqueda.
  2. En la lógica de filtrado, además del filtro por título que ya tienes, aplica un segundo filtro: si $estado !== 'todos', deja solo las incidencias cuyo campo estado sea igual a $estado. Si vale 'todos', no se aplica filtro de estado. Los dos filtros se aplican combinadamente: si el usuario busca "VPN" y selecciona "abierta", solo aparecen las incidencias que cumplan ambas cosas.
  3. En el HTML del formulario, dentro del mismo <form method="get"> (junto al input de búsqueda y el botón "Buscar"), añade un <select name="estado"> con tres opciones: todos, abierta, resuelta. La opción seleccionada por defecto debe ser la que coincida con el valor de $estado (igual que en el Paso 3 hicimos con la prioridad en nueva.php).

Requisitos de presentación HTML:

  • Al cargar http://gestor.local/listado.php sin ningún parámetro, el <select> muestra "Todos" seleccionado y se ven las 5 incidencias.
  • Al seleccionar "Abierta" y pulsar Buscar, la URL pasa a listado.php?busqueda=&estado=abierta y solo aparecen las 3 incidencias con estado abierta. Fíjate en que busqueda= aparece vacío en la URL aunque el input estuviera vacío: por defecto, un <form method="get"> envía todos los campos del formulario, incluso los que dejaste sin rellenar.
  • Al combinar: buscar "VPN" + seleccionar "Abierta", la URL es ?busqueda=VPN&estado=abierta y solo aparece "VPN no conecta".
  • Al pulsar Limpiar, vuelve a listado.php pelado y se ven las 5.

Requisitos de calidad del código:

  • Sigue el patrón de trim($_GET['estado'] ?? 'todos') para leer el parámetro.
  • Usa el mismo patrón de filtrado que ya tienes en el Paso 1 (foreach sobre $todas o sobre el resultado del filtro de título, con un if que decida si una incidencia entra o no en $incidencias). No hay sintaxis nueva.
  • Valida los valores admitidos: si alguien escribe ?estado=imposible directamente en la URL, no debe romper la página (basta con que el filtro no encuentre nada, no que aparezca un error). La forma natural de conseguirlo es no aplicar el filtro si $estado no está en la lista ['abierta', 'resuelta'] o vale 'todos'.
Pista sobre el select con selected

Es el mismo patrón que viste con la prioridad en el Paso 3, pero ahora aplicado al estado:

<select name="estado">
  <?php foreach (['todos', 'abierta', 'resuelta'] as $op): ?>
    <option value="<?= $op ?>" <?= $estado === $op ? 'selected' : '' ?>>
      <?= ucfirst($op) ?>
    </option>
  <?php endforeach ?>
</select>
Pista sobre el doble filtrado

Una idea de estructura: empieza con $incidencias = $todas; y aplícale dos filtros en cascada, cada uno con un foreach que reconstruye la lista. El primero (búsqueda por título) es básicamente el del Paso 1, pero filtrando sobre $incidencias en lugar de sobre $todas. El segundo (filtro de estado) tiene la misma forma, pero con la condición $inc['estado'] === $estado. Antes de aplicar el segundo filtro, comprueba con in_array($estado, ['abierta', 'resuelta'], true) que el valor es uno de los esperados: si vale 'todos' o cualquier cosa inventada, no apliques el filtro de estado y déjalo pasar.

Tienes en el Paso 1 el patrón exacto de cómo construir un foreach que filtra: clónalo y cámbiale la condición.

Responde en RESPUESTAS.md, sección ## Paso 6.

  1. Al hacer una búsqueda combinada (por ejemplo, "VPN" + estado "abierta"), ¿qué aparece en la URL del navegador? Copia la URL completa.
  2. ¿Qué ventaja tiene comprobar in_array($estado, ['abierta', 'resuelta'], true) antes de aplicar el filtro de estado, en lugar de aplicarlo siempre? Piensa en qué pasa si alguien teclea ?estado=cualquiercosa directamente.

Captura requerida. Visita http://gestor.local/listado.php?busqueda=&estado=abierta (o usa el selector para llegar a esa URL) y guarda una captura en capturas/paso6_filtro_estado.png. Debe verse:

  • La URL con estado=abierta en la barra de direcciones.
  • El <select> mostrando "Abierta" seleccionado.
  • La tabla con las 3 incidencias de estado abierta (Servidor web caído, VPN no conecta, Backup no se ejecuta).

Verificación.

  • listado.php lee $_GET['estado'] con trim() y valor por defecto 'todos'.
  • El formulario tiene un <select name="estado"> con tres opciones.
  • La opción del selector que aparece marcada coincide con el valor de $estado.
  • Búsqueda y filtro por estado se combinan: buscar "VPN" + "abierta" deja una sola fila.
  • ?estado=cualquiercosa no rompe la página.
  • php -l ~/gestor/public/listado.php no da errores.
  • He guardado capturas/paso6_filtro_estado.png.

Si tu Paso 6 deja listado.php con error de sintaxis

Antes de hacer el ZIP, deshaz tus cambios del Paso 6 en listado.php y vuelve a la versión que tenías al final del Paso 5. Es preferible entregar el Paso 6 sin hacer (perdiendo solo los puntos de ese paso) que entregar un listado.php roto que rompa también lo del Paso 1 y el Paso 5. Comprueba con php -l ~/gestor/public/listado.php antes de empaquetar.


Checklist final de entrega

Antes de subir el ZIP al Campus, revisa uno a uno:

  • He creado la carpeta apellido_nombre_lab06/ (con mi apellido y nombre reales).
  • Dentro hay gestor/public/listado.php con la versión final del Paso 6.
  • Dentro hay gestor/public/nueva.php con la versión final del Paso 5.
  • Dentro hay gestor/templates/_header.php con session_start() añadido.
  • Dentro hay capturas/ con los 6 PNG: paso1_busqueda_get.png, paso2_var_dump_post.png, paso3_validacion_errores.png, paso4_devtools_prg.png, paso5_flash_exito.png, paso6_filtro_estado.png.
  • Dentro hay RESPUESTAS.md con las 6 secciones rellenas.
  • Los tres ficheros PHP pasan php -l sin errores:
    • php -l ~/gestor/public/listado.php
    • php -l ~/gestor/public/nueva.php
    • php -l ~/gestor/templates/_header.php
  • El ZIP se llama apellido_nombre_lab06.zip y al descomprimirlo solo aparece una carpeta raíz con ese nombre.
  • He subido el ZIP a la tarea correspondiente en el Campus antes de la fecha límite.

Anexo — Plantilla de RESPUESTAS.md

Crea un fichero llamado RESPUESTAS.md dentro de apellido_nombre_lab06/ con esta plantilla y rellena cada sección:

# Respuestas — Lab 06 Formularios, validación, PRG y flash

**Nombre:** Apellido, Nombre
**Fecha:** YYYY-MM-DD

## Paso 1

1. (URL completa que aparece al buscar "VPN".)
2. (Por qué es importante `value="<?= htmlspecialchars($busqueda) ?>"` y qué pasaría sin él.)

## Paso 2

1. (¿Aparece la clave `descripcion` en `$_POST` cuando dejas el textarea vacío? ¿Con qué valor?)
2. (¿Qué valor tiene `prioridad` cuando dejas la opción `-- Selecciona --`?)
3. (¿Qué pasaría si tu PHP usara `$_POST['titulo']` directamente al cargar la página por primera vez?)

## Paso 3

1. (Por qué validar en el servidor si el navegador ya valida con `required` y similares. Ejemplo de caso en que la validación del navegador no sirve.)
2. (Por qué usamos `in_array(..., ..., true)` con `true` como tercer parámetro y qué riesgo tiene omitirlo.)

## Paso 4

1. (Código HTTP del POST y código HTTP del GET.)
2. (Qué pasaría si quitaras el `exit;` después de `header('Location: ...');`.)

## Paso 5

1. (Por qué `unset($_SESSION['flash'])` es importante y qué pasaría sin él al recargar varias veces.)
2. (Por qué `session_start()` debe ir antes de cualquier salida HTML.)

## Paso 6

1. (URL completa con búsqueda "VPN" y estado "abierta" combinados.)
2. (Qué ventaja tiene comprobar `in_array($estado, ['abierta', 'resuelta'], true)` antes de aplicar el filtro.)