🎼 Symfony a fondo
Instalación con Symfony CLI, routing y controllers, contenedor de dependencias con autowiring, Doctrine, Twig, formularios, Messenger, EventDispatcher, Security, API Platform y testing.
Symfony a fondo
Symfony es el framework PHP por excelencia para aplicaciones empresariales: componentes desacoplados, inyección de dependencias real, Doctrine como ORM Data Mapper y API Platform para APIs de primer nivel. Este artículo recorre la arquitectura y construye una API completa.
Instalación con Symfony CLI
symfony new mi-app --full # app completa (web + Doctrine + Twig...)
symfony new api-demo --api # solo para API (API Platform)
cd mi-app
symfony server:start # https://localhost:8000
symfony console make:controller # generador de código
💡
symfony check:requirementsvalida tu PHP antes de arrancar. En Windows se descarga el.exedesde symfony.com/download; en Linux/macOS concurl -sS https://get.symfony.com/cli/installer | bash.
Estructura de directorios
config/
├── packages/ # configuración por bundle
├── routes.yaml # rutas
└── services.yaml # servicios y DI
migrations/ # versiones de esquema de Doctrine
public/index.php # front controller único
src/
├── Controller/ # controllers
├── Entity/ # entidades Doctrine
├── Repository/ # repositorios
├── Service/ # lógica de negocio
├── EventSubscriber/ # listeners del EventDispatcher
└── DataFixtures/ # datos de prueba
templates/ # plantillas Twig
tests/ # tests (PHPUnit + Panther)
💡 Symfony usa bundles: paquetes de funcionalidad activados en
config/bundles.php.framework-bundle,doctrine-bundleytwig-bundleson el núcleo de una app típica.
Routing y controllers
Las rutas se definen con attributes PHP o YAML/XML. El controller es un método de una clase sin heredar nada (POCO):
// src/Controller/PostController.php
namespace App\Controller;
use App\Entity\Post;
use App\Repository\PostRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class PostController
{
#[Route('/posts', name: 'posts_index', methods: ['GET'])]
public function index(PostRepository $repo): Response
{
return $this->render('posts/index.html.twig', [
'posts' => $repo->findBy([], ['createdAt' => 'DESC']),
]);
}
#[Route('/posts/{id}', name: 'posts_show', requirements: ['id' => '\d+'])]
public function show(Post $post): Response // auto-resolución del parámetro
{
return new Response($post->getTitle());
}
}
💡 El
ParamConverterde Doctrine resuelvePost $postpor el id de la ruta: si no existe devuelve 404. Sin magia, solo inyección por tipo + conversión.
Servicios y contenedor de dependencias
Symfony es un contenedor de dependencias: cada clase es un servicio y se inyectan entre sí. El corazón está en config/services.yaml:
# config/services.yaml
services:
_defaults:
autowire: true # inyecta por tipo automáticamente
autoconfigure: true # registra eventos, tags, etc.
App\:
resource: '../src/'
exclude: '../src/{DependencyInjection,Entity,Kernel.php}'
// Inyección por constructor (la forma recomendada)
namespace App\Service;
use App\Repository\PostRepository;
use Psr\Log\LoggerInterface;
class PostService
{
public function __construct(
private readonly PostRepository $postRepository,
private readonly LoggerInterface $logger,
) {}
}
⚠️ No uses
newpara construir servicios dentro de otros: pierdes testabilidad. Inyecta la dependencia. El contenedor además detecta bucles de dependencias y te avisa.
El ciclo de vida del contenedor: compile (analiza y genera código) y cache (var/cache/ guarda el contenedor compilado); en producción se compila una vez. Inspecciona servicios con bin/console cache:clear y bin/console debug:container PostService.
Doctrine
Doctrine es el ORM Data Mapper: las entidades son PHP puros que no conocen la BD; los repositorios hablan con la base. Esto difiere del Active Record de Eloquent.
bin/console make:entity Post
// src/Entity/Post.php
namespace App\Entity;
use App\Repository\PostRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PostRepository::class)]
class Post
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\Column(type: Types::TEXT)]
private string $body;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $publishedAt = null;
#[ORM\ManyToOne(inversedBy: 'posts')]
#[ORM\JoinColumn(nullable: false)]
private ?User $author = null;
}
// src/Repository/PostRepository.php
namespace App\Repository;
use App\Entity\Post;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class PostRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Post::class);
}
}
Relaciones
| Relación | Anotación | Lado propietario |
|---|---|---|
| 1:N | OneToMany / ManyToOne |
el ManyToOne |
| 1:1 | OneToOne |
uno de los dos |
| N:N | ManyToMany |
uno de los dos (con inversedBy) |
// User.php
#[ORM\OneToMany(mappedBy: 'author', targetEntity: Post::class)]
private Collection $posts = new ArrayCollection();
⚠️ En Doctrine el lado propietario (el de la FK) decide la relación. Las colecciones en memoria no se guardan solas: necesitas
$em->flush()después deadd()/remove().
Migraciones
bin/console make:migration
bin/console doctrine:migrations:migrate
bin/console doctrine:fixtures:load
Twig
Twig es el motor de plantillas: sandbox, herencia y autoescape por defecto.
{# templates/base.html.twig #}
<!DOCTYPE html>
<html lang="es">
<head>
<title>{% block title %}Mi App{% endblock %}</title>
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
{# Vista hija: hereda y rellena los bloques #}
{% extends 'base.html.twig' %}
{% block body %}
{% for post in posts %}
<article><h2>{{ post.title }}</h2></article>
{% endfor %}
{% endblock %}
return $this->render('posts/index.html.twig', ['posts' => $posts]);
💡 Twig escapa todo con
{{ }}por defecto. Solo usa{{ contenido|raw }}con contenido de tu confianza; nunca con input del usuario.
Formularios y validación
// src/Form/PostType.php — formulario ligado a la entidad
use App\Entity\Post;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PostType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('title', TextType::class)
->add('body', TextareaType::class)
->add('guardar', SubmitType::class);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Post::class]);
}
}
La validación se declara con atributos en la entidad (#[Assert\NotBlank], #[Assert\Length]) y en el controller se procesa con $form->handleRequest($request) antes de persistir y redirigir.
Messenger: colas y handlers
Messenger lleva el trabajo pesado a procesos asíncronos. Un mensaje (DTO inmutable, p. ej. EnviarNewsletter con un postId) y un handler:
// src/MessageHandler/EnviarNewsletterHandler.php
namespace App\MessageHandler;
use App\Message\EnviarNewsletter;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class EnviarNewsletterHandler
{
public function __invoke(EnviarNewsletter $message): void
{
// trabajo pesado: emails, PDFs, APIs externas
}
}
# config/packages/messenger.yaml
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%' # redis://... o doctrine://default
routing:
'App\Message\EnviarNewsletter': async
💡 Procesa la cola con
bin/console messenger:consume async(worker) e inspecciona fallos conmessenger:failed:show. Si falla, Messenger reencola con retries configurables; puedes usar fanout (varias rutas) y stamps para metadata.
EventDispatcher
Eventos para desacoplar: tu código emite eventos y otros escuchan sin conocerse. Un subscriber reacciona a un evento:
// src/EventSubscriber/PostSubscriber.php
namespace App\EventSubscriber;
use App\Event\PostPublicadoEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class PostSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [PostPublicadoEvent::NAME => 'onPublicado'];
}
public function onPublicado(PostPublicadoEvent $event): void
{
// reaccionar: email, cache inválida, logs...
}
}
💡 El
autoconfigure: truedelservices.yamlregistra los subscribers automáticamente. El evento es un DTO (PostPublicadoEvent) y se emite con$dispatcher->dispatch($evento, PostPublicadoEvent::NAME).
Security: authenticators, voters y firewall
Security combina firewall (qué rutas protege), authenticator (cómo se loguea) y voters (si puede hacer algo).
# config/packages/security.yaml
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
app_user_provider:
entity: { class: App\Entity\User, property: email }
firewalls:
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
$this->denyAccessUnlessGranted('POST_EDIT', $post);
// En Twig: {% if is_granted('POST_EDIT', post) %}...{% endif %}
API Platform
API Platform genera REST, GraphQL, OpenAPI y un Swagger UI desde las entidades. Se instala con composer require api y la documentación interactiva queda en /api.
💡 API Platform resuelve por ti: paginación, filtros (
?title=foo,?order[createdAt]=desc), validación, serialización y el contrato OpenAPI. Es la opción más productiva para API del ecosistema PHP.
Testing
PHPUnit es la base; WebTestCase lanza la app en memoria; Panther añade navegador real. Se ejecuta con bin/phpunit, y para E2E se instala symfony/panthère.
Buenas prácticas (Symfony Best Practices)
- Entidades anémicas: solo datos y validación. La lógica va en servicios.
- Controllers delgados: reciben request, delegan en servicios, devuelven response.
- Inyección por constructor:
readonly+ constructor promotion en PHP 8. - DTOs para entrada/salida; no expongas entidades crudas.
- Mensajes y eventos para desacoplar colas y side-effects.
- Tests para lo que importa: lógica de negocio y contratos de API.
Ejemplo completo: API con API Platform
symfony new api-demo --api
composer require orm:*
bin/console make:entity Post --api-resource # campos: title, body, publishedAt
bin/console make:user
bin/console make:migration && bin/console doctrine:migrations:migrate
bin/console doctrine:fixtures:load
symfony server:start # abre /api/docs
La API resultante ofrece, sin escribir ni un controller:
| Método | Ruta | Acción |
|---|---|---|
GET |
/api/posts |
colección paginada + filtros |
GET |
/api/posts/{id} |
detalle |
POST |
/api/posts |
crear (requiere login) |
PUT |
/api/posts/{id} |
editar (voter) |
DELETE |
/api/posts/{id} |
borrar |
💡 Con
--api-resource, API Platform añade#[ApiResource]a la entidad y filtros por defecto. El contrato OpenAPI se genera solo, en/api/docs.json.
Cheatsheet
| Tarea | Comando |
|---|---|
| Crear app | symfony new app --full |
| Servidor dev | symfony server:start |
| Generar controller | bin/console make:controller |
| Entidad + repositorio | bin/console make:entity |
| Migración | bin/console make:migration + doctrine:migrations:migrate |
| Formulario | bin/console make:form PostType |
| Cola | bin/console messenger:consume async |
| Tests | bin/phpunit |
Para profundizar
- Documentación oficial de Symfony: routing, Doctrine, Security, Messenger, todo con guías.
- Symfony Best Practices: convenciones oficiales de estructura y diseño.
- API Platform Docs: recursos, filtros, security y GraphQL.
- Doctrine ORM Docs: el ORM Data Mapper en profundidad.
- Twig Docs: sintaxis, herencia y funciones.
- Ruta completa: Backend con PHP.