🔎 Buscar

🎼 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.

Wiki / Apuntes📖 Contenido

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:requirements valida tu PHP antes de arrancar. En Windows se descarga el .exe desde symfony.com/download; en Linux/macOS con curl -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-bundle y twig-bundle son 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 ParamConverter de Doctrine resuelve Post $post por 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 new para 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 de add()/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 con messenger: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: true del services.yaml registra 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

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.