Módulo 02: Rutas y controladores

Objetivos del módulo

  • Definir rutas con el atributo #[Route]: path, nombre y métodos HTTP
  • Agrupar rutas con un prefijo a nivel de clase
  • Usar parámetros de ruta con requisitos, valores por defecto y el enum Requirement
  • Depurar rutas con debug:router y router:match
  • Generar URLs desde Twig, desde controladores y desde servicios
  • Dominar los atajos de AbstractController
  • Leer datos de la petición con el objeto Request y construir distintos tipos de Response
  • Inyectar servicios en las acciones
  • Mapear automáticamente parámetros con #[MapQueryParameter], #[MapQueryString] y #[MapRequestPayload]
  • Conocer #[MapEntity] y la carga automática de entidades
  • Trabajar con sesiones y mensajes flash
  • Lanzar excepciones HTTP y personalizar las páginas de error
  • Crear rutas localizadas para varios idiomas
  • Construir las primeras páginas del blog: portada, listado, detalle y “Sobre nosotros”

1. Rutas y controladores: la idea

📘 Concepto: Una ruta es la asociación entre una URL (y opcionalmente un método HTTP) y un controlador. Un controlador es una función PHP (normalmente un método de una clase) que recibe la petición y devuelve un objeto Response.

Piensa en la recepción de un hotel: el router es el recepcionista que mira tu reserva (la URL) y te indica a qué habitación (controlador) debes ir. Si no hay reserva, te dice “no encontrado” (404).

flowchart LR
    U["GET /articulos/hola-symfony"] --> R{"Router"}
    R -->|"coincide con article_show"| C["ArticleController::show(slug)"]
    R -->|"ninguna coincide"| E["404 Not Found"]
    C --> RESP["Response HTML"]

Tu primera ruta con atributos

<?php
// src/Controller/PageController.php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class PageController extends AbstractController
{
    #[Route('/sobre-nosotros', name: 'app_about')]
    public function about(): Response
    {
        return $this->render('page/about.html.twig');
    }
}
Parte Significado
#[Route(...)] Atributo PHP 8 que define la ruta justo encima del método
'/sobre-nosotros' El path: la parte de la URL tras el dominio
name: 'app_about' Nombre único de la ruta; se usa para generar URLs
Response Todo controlador debe devolver una respuesta

⚠️ Importante: El atributo se importa de Symfony\Component\Routing\Attribute\Route. En tutoriales antiguos verás Symfony\Component\Routing\Annotation\Route: ese namespace ya no existe en Symfony 8.

💡 Consejo: Da siempre un nombre a tus rutas. Si no lo haces, Symfony genera uno automático (como app_page_about), pero si un día cambias la clase o el método el nombre cambiará y romperás los enlaces.


2. Métodos HTTP y prefijos

Restringir métodos

#[Route('/contacto', name: 'app_contact', methods: ['GET'])]
public function contact(): Response
{
    return $this->render('page/contact.html.twig');
}

#[Route('/contacto', name: 'app_contact_send', methods: ['POST'])]
public function sendContact(): Response
{
    // procesar el formulario...
    return $this->redirectToRoute('app_home');
}

Ambas rutas comparten URL, pero una responde a GET (mostrar el formulario) y otra a POST (procesarlo). Si llega un método no permitido, Symfony responde 405 Method Not Allowed.

Prefijo a nivel de clase

Cuando varias rutas comparten inicio de URL y de nombre, pon un #[Route] en la clase:

<?php
// src/Controller/ArticleController.php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/articulos', name: 'article_')]
final class ArticleController extends AbstractController
{
    #[Route('', name: 'index', methods: ['GET'])]           // /articulos      → article_index
    public function index(): Response { /* ... */ }

    #[Route('/nuevo', name: 'new', methods: ['GET', 'POST'])] // /articulos/nuevo → article_new
    public function new(): Response { /* ... */ }

    #[Route('/{slug}', name: 'show', methods: ['GET'])]     // /articulos/{slug} → article_show
    public function show(string $slug): Response { /* ... */ }
}

⚠️ Importante: El orden importa. Las rutas se evalúan de arriba abajo; si /{slug} estuviera antes que /nuevo, la URL /articulos/nuevo se interpretaría como un artículo con slug “nuevo”. Coloca las rutas fijas antes que las variables, o usa la opción priority (un entero; mayor prioridad = se evalúa antes).

#[Route('/nuevo', name: 'new', priority: 10)]

Otras opciones de #[Route]

Opción Ejemplo Para qué
path '/articulos/{slug}' La URL (primer argumento)
name 'article_show' Nombre de la ruta
methods ['GET', 'POST'] Métodos permitidos
requirements ['id' => '\d+'] Expresiones regulares para los parámetros
defaults ['page' => 1] Valores por defecto
priority 10 Orden de evaluación
condition "request.headers.get('X-Api') == '1'" Condición con ExpressionLanguage
schemes ['https'] Forzar HTTPS
host 'admin.miblog.com' Restringir a un dominio
env 'dev' La ruta solo existe en ese entorno
locale, format 'es', 'json' Valores de _locale y _format

3. Parámetros de ruta

Las partes de la URL entre llaves son parámetros. Symfony los pasa al controlador como argumentos con el mismo nombre:

#[Route('/articulos/{slug}', name: 'article_show')]
public function show(string $slug): Response
{
    return new Response("Estás leyendo el artículo: $slug");
}

/articulos/hola-symfony → $slug = 'hola-symfony'.

Si tipas el argumento como int, Symfony convierte el valor:

#[Route('/autor/{id}/articulos/{page}', name: 'author_articles')]
public function byAuthor(int $id, int $page): Response
{
    return new Response("Autor $id, página $page");
}

Requisitos (requirements)

Sin requisitos, {id} acepta cualquier cosa: /autor/abc provocaría un error al convertir “abc” a int. Los requisitos restringen los valores con expresiones regulares:

#[Route('/autor/{id}', name: 'author_show', requirements: ['id' => '\d+'])]
public function author(int $id): Response { /* ... */ }

Ahora /autor/abc simplemente no coincide con la ruta (404), en lugar de fallar.

Sintaxis en línea

Puedes escribir el requisito dentro del propio path, entre < >:

#[Route('/autor/{id<\d+>}', name: 'author_show')]
public function author(int $id): Response { /* ... */ }

El enum Requirement

Para no escribir (y equivocarte en) expresiones regulares típicas, Symfony ofrece constantes:

use Symfony\Component\Routing\Requirement\Requirement;

#[Route('/articulos/{slug}', name: 'article_show', requirements: ['slug' => Requirement::ASCII_SLUG])]
public function show(string $slug): Response { /* ... */ }

#[Route('/archivo/{date}', name: 'article_archive', requirements: ['date' => Requirement::DATE_YMD])]
public function archive(string $date): Response { /* ... */ }
Constante Acepta
Requirement::DIGITS Uno o más dígitos (0, 42)
Requirement::POSITIVE_INT Enteros positivos (1, 42), sin ceros a la izquierda
Requirement::ASCII_SLUG Slugs en minúsculas: hola-symfony-8
Requirement::DATE_YMD Fechas 2026-10-03
Requirement::UUID Cualquier UUID
Requirement::ULID ULIDs
Requirement::CATCH_ALL Cualquier cosa, incluidas barras /

Valores por defecto y parámetros opcionales

Un parámetro es opcional si tiene valor por defecto. La forma más sencilla es darle valor en el argumento PHP:

#[Route('/articulos/pagina/{page}', name: 'article_paginated', requirements: ['page' => Requirement::POSITIVE_INT])]
public function paginated(int $page = 1): Response
{
    // /articulos/pagina   → $page = 1
    // /articulos/pagina/3 → $page = 3
    return new Response("Página $page");
}

También en línea, con ?valor tras el requisito:

#[Route('/articulos/pagina/{page<\d+>?1}', name: 'article_paginated')]
public function paginated(int $page): Response { /* ... */ }

⚠️ Importante: Solo pueden ser opcionales los parámetros del final. En /blog/{page}/{orden}, page no puede ser opcional si orden no lo es.

Parámetros especiales

Parámetro Efecto
_locale Fija el idioma de la petición
_format Fija el formato (html, json, xml) y el Content-Type de la respuesta
_fragment Añade un ancla #... al generar la URL
_controller, _route Internos: controlador y nombre de la ruta coincidente
#[Route('/articulos/{slug}.{_format}', name: 'article_export', requirements: ['_format' => 'html|json'], format: 'html')]
public function export(string $slug, string $_format): Response { /* ... */ }

4. Depurar rutas

debug:router

php bin/console debug:router
 ------------------ ---------- -------- ------ --------------------------
  Name               Method     Scheme   Host   Path
 ------------------ ---------- -------- ------ --------------------------
  app_home           GET        ANY      ANY    /
  app_about          ANY        ANY      ANY    /sobre-nosotros
  article_index      GET        ANY      ANY    /articulos
  article_new        GET|POST   ANY      ANY    /articulos/nuevo
  article_show       GET        ANY      ANY    /articulos/{slug}
 ------------------ ---------- -------- ------ --------------------------

Opciones útiles:

php bin/console debug:router article_show       # detalle de una ruta
php bin/console debug:router --show-controllers # añade la columna del controlador
php bin/console debug:router --method=POST      # filtra por método

router:match

Pregunta al router qué ruta coincide con una URL:

php bin/console router:match /articulos/hola-symfony

💡 Consejo: Cuando una URL “no hace lo que esperas”, router:match es lo primero que debes probar. Te dirá si otra ruta la está capturando antes.


5. Generar URLs

⚠️ Importante: Nunca escribas URLs a mano (href="/articulos/hola"). Genera siempre las URLs a partir del nombre de la ruta. Así, si mañana cambias /articulos por /blog, todos los enlaces se actualizan solos.

En Twig

{# Ruta relativa: /articulos/hola-symfony #}
<a href="{{ path('article_show', {slug: 'hola-symfony'}) }}">Leer</a>

{# URL absoluta: https://miblog.com/articulos/hola-symfony (para emails, RSS...) #}
<a href="{{ url('article_show', {slug: 'hola-symfony'}) }}">Leer</a>

{# Los parámetros que no están en la ruta se añaden como query string #}
<a href="{{ path('article_index', {page: 2, orden: 'fecha'}) }}">Página 2</a>
{# → /articulos?page=2&orden=fecha #}

En un controlador

use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

$url = $this->generateUrl('article_show', ['slug' => 'hola-symfony']);
// /articulos/hola-symfony

$absolute = $this->generateUrl('article_show', ['slug' => 'hola-symfony'], UrlGeneratorInterface::ABSOLUTE_URL);
// https://127.0.0.1:8000/articulos/hola-symfony

En un servicio

Inyecta UrlGeneratorInterface:

// src/Service/ArticleLinkBuilder.php
final readonly class ArticleLinkBuilder
{
    public function __construct(private UrlGeneratorInterface $urlGenerator) {}

    public function shareUrl(string $slug): string
    {
        return $this->urlGenerator->generate('article_show', ['slug' => $slug], UrlGeneratorInterface::ABSOLUTE_URL);
    }
}

💡 Consejo: En comandos de consola no hay petición HTTP, así que Symfony no sabe el dominio. Configúralo con la variable DEFAULT_URI del .env (la recipe de Routing ya la añade con http://localhost).


6. AbstractController y sus atajos

Extender AbstractController es opcional, pero te da un montón de métodos útiles:

Método Qué hace
render($vista, $vars) Renderiza una plantilla Twig y devuelve una Response
renderView($vista, $vars) Igual, pero devuelve el HTML como string
renderBlock($vista, $bloque, $vars) Renderiza solo un bloque de una plantilla
json($datos, $status, $headers, $context) Devuelve una JsonResponse (usa el Serializer si está instalado)
redirectToRoute($ruta, $params, $status) Redirige a una ruta por su nombre (302 por defecto)
redirect($url) Redirige a una URL arbitraria
generateUrl($ruta, $params) Genera una URL
file($fichero, $nombre) Descarga un fichero (BinaryFileResponse)
addFlash($tipo, $mensaje) Añade un mensaje flash a la sesión
createNotFoundException($msg) Crea una excepción 404 (debes lanzarla con throw)
createAccessDeniedException($msg) Crea una excepción 403
getUser() Devuelve el usuario autenticado o null (módulo 10)
isGranted($rol) Comprueba un permiso (módulo 10)
denyAccessUnlessGranted($rol) Lanza 403 si no tiene el permiso
createForm($tipo, $datos) Crea un formulario (módulo 07)
getParameter($nombre) Lee un parámetro del contenedor
isCsrfTokenValid($id, $token) Valida un token CSRF
#[Route('/articulos/{slug}/descargar', name: 'article_download')]
public function download(string $slug): Response
{
    $path = $this->getParameter('kernel.project_dir') . "/var/pdf/$slug.pdf";

    if (!is_file($path)) {
        throw $this->createNotFoundException("No hay PDF para el artículo $slug");
    }

    return $this->file($path, "$slug.pdf");
}

7. El objeto Request

📘 Concepto: Symfony\Component\HttpFoundation\Request representa la petición HTTP como objeto. Sustituye a las superglobales $_GET, $_POST, $_SERVER, $_COOKIE, $_FILES. Para obtenerlo, basta con tiparlo como argumento del controlador.

use Symfony\Component\HttpFoundation\Request;

#[Route('/buscar', name: 'article_search')]
public function search(Request $request): Response
{
    // /buscar?q=symfony&page=2
    $q = $request->query->getString('q');        // 'symfony'
    $page = $request->query->getInt('page', 1);  // 2 (1 si no viene)

    return new Response("Buscando '$q' en la página $page");
}

Las “bolsas” de parámetros

Propiedad Equivale a Ejemplo
$request->query $_GET $request->query->get('q')
$request->request $_POST (formularios) $request->request->get('title')
$request->attributes Datos de la ruta $request->attributes->get('_route')
$request->headers Cabeceras HTTP $request->headers->get('User-Agent')
$request->cookies $_COOKIE $request->cookies->get('tema')
$request->files $_FILES $request->files->get('imagen')
$request->server $_SERVER $request->server->get('REMOTE_ADDR')

Métodos de las bolsas: get(), has(), all(), getInt(), getString(), getBoolean(), getAlpha(), getEnum().

Cuerpo de la petición

// Cuerpo crudo (string)
$raw = $request->getContent();

// Cuerpo JSON decodificado como array (lanza excepción si no es JSON válido)
$data = $request->toArray();

// getPayload(): funciona igual con formularios (POST) o con JSON
$title = $request->getPayload()->getString('title');

💡 Consejo: getPayload() es la forma recomendada de leer el cuerpo: devuelve una InputBag tanto si te envían application/x-www-form-urlencoded como application/json.


8. El objeto Response

Response básica

use Symfony\Component\HttpFoundation\Response;

$response = new Response(
    content: '<h1>Hola</h1>',
    status: Response::HTTP_OK,                  // 200
    headers: ['Content-Type' => 'text/html; charset=UTF-8'],
);

$response->headers->set('X-Blog-Version', '1.0');
$response->setStatusCode(Response::HTTP_CREATED); // 201

Códigos de estado más habituales

Constante Código Cuándo
HTTP_OK 200 Todo correcto
HTTP_CREATED 201 Recurso creado (APIs)
HTTP_NO_CONTENT 204 Correcto, sin cuerpo
HTTP_MOVED_PERMANENTLY 301 Redirección permanente (SEO)
HTTP_FOUND 302 Redirección temporal
HTTP_SEE_OTHER 303 Redirección tras un POST
HTTP_BAD_REQUEST 400 Petición mal formada
HTTP_UNAUTHORIZED 401 Falta autenticación
HTTP_FORBIDDEN 403 Sin permiso
HTTP_NOT_FOUND 404 No existe
HTTP_METHOD_NOT_ALLOWED 405 Método no permitido
HTTP_UNPROCESSABLE_ENTITY 422 Datos no válidos
HTTP_INTERNAL_SERVER_ERROR 500 Error del servidor

Tipos de respuesta especializados

use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;

// JSON (o usa $this->json())
return new JsonResponse(['title' => 'Hola Symfony', 'comments' => 3]);

// Redirección (o usa $this->redirectToRoute())
return new RedirectResponse('/articulos', Response::HTTP_MOVED_PERMANENTLY);

// Fichero
$response = new BinaryFileResponse('/ruta/a/manual.pdf');
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'manual.pdf');
return $response;

// Respuesta "en streaming": se envía a trozos (ideal para CSV grandes)
return new StreamedResponse(function (): void {
    $out = fopen('php://output', 'w');
    fputcsv($out, ['id', 'titulo']);
    foreach ([[1, 'Hola Symfony'], [2, 'Twig a fondo']] as $row) {
        fputcsv($out, $row);
        flush();
    }
    fclose($out);
}, Response::HTTP_OK, ['Content-Type' => 'text/csv']);

9. Inyección de servicios en las acciones

Además de los parámetros de ruta y la Request, puedes pedir cualquier servicio como argumento de una acción. Symfony lo inyecta gracias al autowiring:

use Psr\Log\LoggerInterface;

#[Route('/sobre-nosotros', name: 'app_about')]
public function about(LoggerInterface $logger): Response
{
    $logger->info('Alguien ha visitado la página Sobre nosotros');

    return $this->render('page/about.html.twig');
}

💡 Consejo: Si un servicio se usa en casi todas las acciones de un controlador, inyéctalo en el constructor en lugar de en cada método. Ejecuta php bin/console debug:autowiring para ver qué tipos puedes inyectar. Profundizarás en el módulo 09.


10. Mapeo automático de la petición

Leer cada parámetro con $request->query->get() y validarlo a mano es tedioso. Symfony puede hacerlo por ti con atributos en los argumentos.

#[MapQueryParameter]: un parámetro de la query string

use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;

#[Route('/articulos', name: 'article_index', methods: ['GET'])]
public function index(
    #[MapQueryParameter] int $page = 1,
    #[MapQueryParameter] ?string $q = null,
    #[MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, options: ['regexp' => '/^(fecha|titulo)$/'])]
    string $orden = 'fecha',
): Response {
    // /articulos?page=2&q=twig&orden=titulo
    // ...
}
  • Convierte el valor al tipo del argumento (int, string, bool, float, array, enums…).
  • Si el valor no es válido (por ejemplo ?page=abc), responde con 404.
  • Si el argumento tiene valor por defecto o es nullable, el parámetro es opcional.

#[MapQueryString]: toda la query string a un DTO

Cuando hay muchos filtros, agrúpalos en una clase (DTO, Data Transfer Object):

<?php
// src/Dto/ArticleSearch.php
declare(strict_types=1);

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class ArticleSearch
{
    public function __construct(
        #[Assert\Length(max: 100)]
        public ?string $q = null,

        public ?string $category = null,

        #[Assert\Positive]
        public int $page = 1,
    ) {
    }
}
use App\Dto\ArticleSearch;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;

#[Route('/buscar', name: 'article_search')]
public function search(#[MapQueryString] ArticleSearch $search = new ArticleSearch()): Response
{
    // /buscar?q=symfony&category=tutoriales&page=2
    // $search->q === 'symfony', $search->page === 2
    return $this->render('article/search.html.twig', ['search' => $search]);
}

El objeto se valida con las restricciones del Validator; si falla, responde 404 (configurable con validationFailedStatusCode).

#[MapRequestPayload]: el cuerpo de la petición a un DTO

Ideal para APIs que reciben JSON (o formularios):

<?php
// src/Dto/NewCommentPayload.php
declare(strict_types=1);

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class NewCommentPayload
{
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Length(max: 50)]
        public string $author,

        #[Assert\NotBlank]
        #[Assert\Length(min: 5, max: 2000)]
        public string $content,
    ) {
    }
}
use App\Dto\NewCommentPayload;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;

#[Route('/api/articulos/{slug}/comentarios', name: 'api_comment_create', methods: ['POST'])]
public function createComment(string $slug, #[MapRequestPayload] NewCommentPayload $payload): JsonResponse
{
    // Si llegamos aquí, el JSON ya está deserializado y validado
    return $this->json([
        'article' => $slug,
        'author' => $payload->author,
        'status' => 'pendiente de moderación',
    ], Response::HTTP_CREATED);
}
curl -X POST https://127.0.0.1:8000/api/articulos/hola-symfony/comentarios \
     -H "Content-Type: application/json" \
     -d '{"author": "Ana", "content": "¡Gran artículo!"}'
# {"article":"hola-symfony","author":"Ana","status":"pendiente de moderación"}

Si el JSON no es válido según las restricciones, Symfony responde automáticamente 422 Unprocessable Entity con el detalle de los errores.

Atributo Fuente Error por defecto
#[MapQueryParameter] Un parámetro de ?query 404
#[MapQueryString] Toda la query string → DTO 404
#[MapRequestPayload] Cuerpo (JSON, formulario) → DTO 422
#[MapUploadedFile] Un fichero subido 422

📘 Concepto: Estos atributos usan por debajo los componentes Serializer y Validator (ambos incluidos en --webapp). Volveremos a ellos en el módulo 08 (Validación) y en el 12 (APIs REST).


11. Carga automática de entidades: #[MapEntity] (adelanto)

Cuando tengas entidades Doctrine (módulo 05), podrás pedir directamente el objeto en lugar del parámetro:

use App\Entity\Article;

// {id} → Symfony hace $repository->find($id). Si no existe: 404
#[Route('/articulos/{id<\d+>}', name: 'article_show_by_id')]
public function showById(Article $article): Response { /* ... */ }

// {slug:article} → busca el artículo cuyo campo slug coincide
#[Route('/articulos/{slug:article}', name: 'article_show')]
public function show(Article $article): Response { /* ... */ }

Lo veremos en detalle en el módulo 05.


12. Sesiones y mensajes flash

La sesión

#[Route('/preferencias/tema/{tema<claro|oscuro>}', name: 'app_theme')]
public function theme(string $tema, Request $request): Response
{
    $session = $request->getSession();
    $session->set('tema', $tema);

    // Más tarde, en otra petición:
    // $tema = $session->get('tema', 'claro');

    return $this->redirectToRoute('app_home');
}
Método Qué hace
set($clave, $valor) Guarda un valor
get($clave, $defecto) Lee un valor
has($clave) ¿Existe?
remove($clave) Elimina un valor
clear() Vacía la sesión
invalidate() Destruye la sesión y crea una nueva

💡 Consejo: Symfony solo arranca la sesión cuando la lees o escribes por primera vez. Así las páginas que no la usan pueden cachearse mejor. En un servicio, obtén la sesión inyectando RequestStack y llamando a $requestStack->getSession().

Mensajes flash

📘 Concepto: Un mensaje flash es un mensaje que se guarda en la sesión y se borra en cuanto se lee. Es perfecto para el patrón “haz algo → redirige → muestra ‘Guardado correctamente’”.

#[Route('/articulos/{slug}/publicar', name: 'article_publish', methods: ['POST'])]
public function publish(string $slug): Response
{
    // ... publicar el artículo

    $this->addFlash('success', "El artículo «$slug» se ha publicado.");
    $this->addFlash('warning', 'Recuerda compartirlo en redes sociales.');

    return $this->redirectToRoute('article_show', ['slug' => $slug]);
}

En la plantilla base, muéstralos con app.flashes:

{# templates/base.html.twig (dentro del body) #}
{% for type, messages in app.flashes %}
    {% for message in messages %}
        <div class="alert alert-{{ type }}">{{ message }}</div>
    {% endfor %}
{% endfor %}

💡 Consejo: Usa como tipo los nombres de las clases de alerta de Bootstrap (success, danger, warning, info): así puedes ponerlos directamente en alert-{{ type }}.


13. Errores y excepciones HTTP

Lanzar errores HTTP

use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

throw $this->createNotFoundException('El artículo no existe');   // 404
throw new NotFoundHttpException('El artículo no existe');         // 404
throw new AccessDeniedHttpException('No puedes editar esto');     // 403
throw new BadRequestHttpException('Parámetro "q" obligatorio');   // 400
throw new HttpException(Response::HTTP_SERVICE_UNAVAILABLE, 'En mantenimiento'); // 503

Cualquier otra excepción no capturada se convierte en un 500.

Puedes asociar un código HTTP a tus propias excepciones con el atributo #[WithHttpStatus]:

<?php
// src/Exception/ArticleNotPublishedException.php
declare(strict_types=1);

namespace App\Exception;

use Symfony\Component\HttpKernel\Attribute\WithHttpStatus;

#[WithHttpStatus(404)]
final class ArticleNotPublishedException extends \RuntimeException
{
}

En dev y en prod

  • En dev verás una página de error detallada con la traza de la excepción.
  • En prod el usuario ve una página genérica (“Oops! An Error Occurred”) sin detalles internos.

⚠️ Importante: El mensaje que pasas a createNotFoundException('...') solo se ve en dev. En producción nunca se muestra al usuario, para no filtrar información.

Páginas de error personalizadas

Crea plantillas en templates/bundles/TwigBundle/Exception/:

templates/bundles/TwigBundle/Exception/
├── error404.html.twig   ← para los 404
├── error403.html.twig   ← para los 403
└── error.html.twig      ← para cualquier otro error
{# templates/bundles/TwigBundle/Exception/error404.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}Página no encontrada{% endblock %}

{% block body %}
    <h1>Vaya, esta página no existe</h1>
    <p>Puede que el artículo se haya borrado o que la dirección esté mal escrita.</p>
    <a href="{{ path('article_index') }}">Ver todos los artículos</a>
{% endblock %}

En desarrollo puedes previsualizarlas sin provocar el error, visitando /_error/404 o /_error/500.

💡 Consejo: Mantén las páginas de error sencillas. Si error.html.twig hace consultas a la base de datos y lo que ha fallado es precisamente la base de datos, la página de error también fallará.


14. Rutas localizadas (i18n)

Si el blog va a estar en varios idiomas, una misma ruta puede tener un path distinto por idioma:

#[Route(path: [
    'es' => '/sobre-nosotros',
    'en' => '/about-us',
], name: 'app_about')]
public function about(): Response { /* ... */ }

Symfony crea internamente app_about.es y app_about.en, y fija el idioma de la petición según la URL. Al generar la URL, usa el idioma actual o indícalo:

<a href="{{ path('app_about') }}">Sobre nosotros</a>
<a href="{{ path('app_about', {_locale: 'en'}) }}">About us</a>

Otra opción muy habitual es poner el idioma como prefijo de todas las rutas:

# config/routes.yaml
controllers:
    resource: routing.controllers
    prefix:
        es: ''      # español sin prefijo: /sobre-nosotros
        en: '/en'   # inglés: /en/about-us

Y declarar los idiomas disponibles:

# config/packages/translation.yaml
framework:
    default_locale: es
    enabled_locales: ['es', 'en']

💡 Consejo: La traducción de textos ({{ 'menu.home'|trans }}) es otro tema; aquí solo ves la parte de rutas. Se verá con más detalle cuando internacionalices el proyecto final.


15. Ejemplo completo: las páginas del blog

Todavía no tenemos base de datos, así que usaremos un servicio con artículos de ejemplo. En el módulo 05 lo sustituiremos por un repositorio de Doctrine sin tocar apenas el controlador.

El servicio con datos de ejemplo

<?php
// src/Service/ArticleCatalog.php
declare(strict_types=1);

namespace App\Service;

final class ArticleCatalog
{
    private const array ARTICLES = [
        'hola-symfony' => ['title' => 'Hola, Symfony 8', 'summary' => 'Primeros pasos con el framework.', 'content' => "Symfony es un framework PHP...\nVeamos cómo empezar.", 'publishedAt' => '2026-09-01'],
        'rutas-con-atributos' => ['title' => 'Rutas con atributos', 'summary' => 'Todo sobre #[Route].', 'content' => 'Las rutas se definen con atributos PHP...', 'publishedAt' => '2026-09-15'],
        'twig-desde-cero' => ['title' => 'Twig desde cero', 'summary' => 'Plantillas limpias y seguras.', 'content' => 'Twig es el motor de plantillas de Symfony...', 'publishedAt' => '2026-10-01'],
    ];

    /** @return array<string, array<string, string>> */
    public function findAll(): array
    {
        return self::ARTICLES;
    }

    /** @return array<string, string>|null */
    public function findOneBySlug(string $slug): ?array
    {
        return self::ARTICLES[$slug] ?? null;
    }
}

El controlador de artículos

<?php
// src/Controller/ArticleController.php
declare(strict_types=1);

namespace App\Controller;

use App\Service\ArticleCatalog;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;

#[Route('/articulos', name: 'article_')]
final class ArticleController extends AbstractController
{
    public function __construct(
        private readonly ArticleCatalog $catalog,
    ) {
    }

    #[Route('', name: 'index', methods: ['GET'])]
    public function index(#[MapQueryParameter] ?string $q = null): Response
    {
        $articles = $this->catalog->findAll();

        if ($q !== null && $q !== '') {
            $articles = array_filter(
                $articles,
                fn (array $a): bool => mb_stripos($a['title'], $q) !== false,
            );
        }

        return $this->render('article/index.html.twig', [
            'articles' => $articles,
            'q' => $q,
        ]);
    }

    #[Route('/{slug}', name: 'show', methods: ['GET'], requirements: ['slug' => Requirement::ASCII_SLUG])]
    public function show(string $slug): Response
    {
        $article = $this->catalog->findOneBySlug($slug)
            ?? throw $this->createNotFoundException("No existe el artículo «$slug»");

        return $this->render('article/show.html.twig', [
            'slug' => $slug,
            'article' => $article,
        ]);
    }
}

Portada y “Sobre nosotros”

<?php
// src/Controller/PageController.php
declare(strict_types=1);

namespace App\Controller;

use App\Service\ArticleCatalog;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class PageController extends AbstractController
{
    #[Route('/', name: 'app_home', methods: ['GET'])]
    public function home(ArticleCatalog $catalog): Response
    {
        // Los dos artículos más recientes
        $latest = array_slice(array_reverse($catalog->findAll(), true), 0, 2, true);

        return $this->render('page/home.html.twig', ['latest' => $latest]);
    }

    #[Route('/sobre-nosotros', name: 'app_about', methods: ['GET'])]
    public function about(): Response
    {
        return $this->render('page/about.html.twig', [
            'team' => ['Ana (editora)', 'Luis (desarrollador)', 'Marta (diseñadora)'],
        ]);
    }
}

Las plantillas (versión mínima, las mejorarás en el módulo 03)

{# templates/article/index.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}Artículos{% endblock %}

{% block body %}
    <h1>Artículos</h1>

    <form method="get" action="{{ path('article_index') }}">
        <input type="search" name="q" value="{{ q }}" placeholder="Buscar...">
        <button>Buscar</button>
    </form>

    {% for slug, article in articles %}
        <article>
            <h2><a href="{{ path('article_show', {slug: slug}) }}">{{ article.title }}</a></h2>
            <p>{{ article.summary }}</p>
        </article>
    {% else %}
        <p>No hay artículos que coincidan con la búsqueda.</p>
    {% endfor %}
{% endblock %}
{# templates/article/show.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}{{ article.title }}{% endblock %}

{% block body %}
    <h1>{{ article.title }}</h1>
    <p><small>Publicado el {{ article.publishedAt|date('d/m/Y') }}</small></p>
    <p>{{ article.content|nl2br }}</p>
    <a href="{{ path('article_index') }}">← Volver al listado</a>
{% endblock %}

✏️ Ejercicio: Crea tú las plantillas page/home.html.twig (recorre latest) y page/about.html.twig (recorre team) siguiendo el mismo patrón.

Prueba:

URL Resultado
/ Portada con los dos últimos artículos
/articulos Listado completo
/articulos?q=twig Solo “Twig desde cero”
/articulos/hola-symfony Detalle del artículo
/articulos/no-existe 404 (con tu página personalizada en prod)
/articulos/Hola_Symfony 404: no cumple ASCII_SLUG
/sobre-nosotros Página “Sobre nosotros”

💡 Consejo: Fíjate en el operador ?? throw: desde PHP 8, throw es una expresión, lo que permite “buscar o lanzar 404” en una sola línea.


16. Ejercicios

Ejercicio 1: Página de contacto

Añade a PageController dos rutas /contacto: una GET (app_contact) que muestre un formulario HTML con los campos nombre, email y mensaje, y otra POST (app_contact_send) que lea los datos con $request->getPayload(), añada un flash de tipo success con “Gracias, {nombre}” y redirija a la portada. Muestra los flashes en base.html.twig.

Ejercicio 2: Archivo por fecha

Crea la ruta /articulos/archivo/{year}/{month} (nombre article_archive) con requisitos: year de 4 dígitos y month de 1 a 12 (0?[1-9]|1[0-2]). month debe ser opcional. Muestra “Artículos de {mes}/{año}” o “Artículos de {año}”. Comprueba con router:match que /articulos/archivo/2026/13 no coincide y asegúrate de que no colisiona con article_show.

Ejercicio 3: Exportar en JSON

Añade la ruta /articulos/{slug}.{_format} con _format restringido a html|json. Si el formato es json, devuelve el artículo con $this->json(); si es html, renderiza la plantilla de detalle.

Ejercicio 4: Búsqueda con DTO

Sustituye el #[MapQueryParameter] de article_index por un DTO ArticleSearch con #[MapQueryString] que tenga q (máximo 100 caracteres) y page (positivo). Prueba qué ocurre con ?page=-1.

Ejercicio 5: API de comentarios

Crea la ruta POST /api/articulos/{slug}/comentarios que reciba un JSON mapeado con #[MapRequestPayload] a NewCommentPayload. Si el artículo no existe en ArticleCatalog, responde 404. Si existe, devuelve 201 con el comentario. Prueba con curl un JSON válido, uno sin content y uno con content de 2 caracteres.

Ejercicio 6: Blog bilingüe

Convierte las rutas app_about y article_index en rutas localizadas (es y en) y añade en la cabecera del blog un selector de idioma que enlace a la misma página en el otro idioma usando app.current_route y app.current_route_parameters.


Resumen

Concepto Sintaxis
Ruta básica #[Route('/sobre-nosotros', name: 'app_about')]
Métodos methods: ['GET', 'POST']
Prefijo de clase #[Route('/articulos', name: 'article_')] en la clase
Parámetro #[Route('/articulos/{slug}')] + string $slug
Requisito requirements: ['id' => '\d+'] o {id<\d+>}
Enum de requisitos Requirement::ASCII_SLUG, DIGITS, POSITIVE_INT, UUID…
Opcional int $page = 1 o {page<\d+>?1}
Prioridad priority: 10
Depurar debug:router, router:match /url
URL en Twig path('article_show', {slug: s}), url(...)
URL en PHP $this->generateUrl(), UrlGeneratorInterface::generate()
Renderizar $this->render('article/show.html.twig', [...])
JSON $this->json($datos, 201)
Redirigir $this->redirectToRoute('app_home')
404 throw $this->createNotFoundException()
Request $request->query, ->request, ->attributes, ->headers, ->getPayload(), ->toArray()
Respuestas Response, JsonResponse, RedirectResponse, BinaryFileResponse, StreamedResponse
Mapeo automático #[MapQueryParameter], #[MapQueryString], #[MapRequestPayload]
Entidades {slug:article} + Article $article, #[MapEntity]
Flash $this->addFlash('success', '...') + app.flashes en Twig
Errores propios templates/bundles/TwigBundle/Exception/error404.html.twig
Rutas localizadas path: ['es' => '/sobre-nosotros', 'en' => '/about-us']