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:routeryrouter:match - Generar URLs desde Twig, desde controladores y desde servicios
- Dominar los atajos de
AbstractController - Leer datos de la petición con el objeto
Requesty construir distintos tipos deResponse - 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ásSymfony\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/nuevose interpretaría como un artículo con slug “nuevo”. Coloca las rutas fijas antes que las variables, o usa la opciónpriority(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},pageno puede ser opcional siordenno 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:matches 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/articulospor/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_URIdel.env(la recipe de Routing ya la añade conhttp://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\Requestrepresenta 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 unaInputBagtanto si te envíanapplication/x-www-form-urlencodedcomoapplication/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:autowiringpara 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
RequestStacky 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 enalert-{{ 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.twighace 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(recorrelatest) ypage/about.html.twig(recorreteam) 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,throwes 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'] |