Módulo 09: Servicios e inyección de dependencias

Objetivos del módulo

  • Entender qué es un servicio y qué hace el contenedor de servicios
  • Leer y modificar config/services.yaml sabiendo qué significa cada línea
  • Comprender el autowiring por tipo y la autoconfiguración
  • Crear servicios propios para el blog e inyectarlos por constructor
  • Trabajar con interfaces, alias y varias implementaciones (#[AsAlias], #[Target], #[Autowire])
  • Usar parámetros, variables de entorno, procesadores de variables y secretos
  • Aplicar atributos de configuración: servicios etiquetados, #[AutowireIterator], #[When], servicios lazy
  • Depurar el contenedor con debug:container y debug:autowiring
  • Configurar bundles por entorno y escribir controladores delgados

1. ¿Qué es un servicio?

📘 Concepto: Un servicio es cualquier objeto PHP que hace un trabajo útil: enviar emails, guardar en la base de datos, generar slugs, convertir Markdown en HTML… Lo que distingue a un servicio de una entidad es que no representa datos, sino comportamiento, y normalmente basta con una única instancia para toda la petición.

Ya has usado muchos servicios sin saberlo: EntityManagerInterface, ArticleRepository, SluggerInterface, ValidatorInterface, el propio motor de Twig… Siempre los pediste escribiendo su tipo en un argumento, y aparecieron.

La analogía de la cocina

Imagina la cocina de un restaurante. El cocinero (tu controlador) no fabrica su propio horno ni va a comprar los cuchillos: los pide y alguien se los proporciona ya montados y listos. Ese “alguien” es el contenedor de servicios:

  • Sabe cómo construir cada servicio (qué necesita su constructor).
  • Lo construye solo cuando alguien lo pide (perezosamente).
  • Guarda la instancia y la reutiliza en toda la petición.
flowchart TB
    subgraph Contenedor["Contenedor de servicios"]
        direction TB
        EM[EntityManager]
        Repo[ArticleRepository]
        Slug[SlugGenerator]
        Pub[ArticlePublisher]
        Log[Logger]
        Repo --> EM
        Slug --> Repo
        Pub --> EM
        Pub --> Slug
        Pub --> Log
    end
    C[ArticleController] -->|pide ArticlePublisher| Pub

📘 Concepto: Inyección de dependencias significa que un objeto recibe lo que necesita (sus dependencias) desde fuera, normalmente por el constructor, en lugar de crearlas él con new. Esto hace el código desacoplado, configurable y, sobre todo, fácil de probar: en un test puedes pasarle una implementación falsa.

// ❌ Sin inyección: acoplado, imposible de sustituir en un test
final class ArticlePublisher
{
    public function publish(Article $article): void
    {
        $em = new EntityManager(/* ¿y la configuración de la BD? */);
        // ...
    }
}

// ✅ Con inyección: recibe lo que necesita
final readonly class ArticlePublisher
{
    public function __construct(private EntityManagerInterface $em)
    {
    }
}

2. config/services.yaml explicado

Este es el fichero que encontrarás en un proyecto nuevo:

# config/services.yaml
parameters:

services:
    # configuración por defecto para los servicios de *este* fichero
    _defaults:
        autowire: true      # inyecta automáticamente las dependencias
        autoconfigure: true # registra automáticamente comandos, listeners, etc.

    # hace que las clases de src/ estén disponibles como servicios
    # el id de cada servicio es el nombre completo de la clase
    App\:
        resource: '../src/'

    # el orden importa: las definiciones posteriores reemplazan a las anteriores
    # añade aquí debajo tu configuración propia
Clave Significado
parameters Valores de configuración reutilizables (sección 7)
_defaults Opciones aplicadas a todos los servicios definidos en este fichero
autowire: true El contenedor lee los tipos del constructor y pasa los servicios correspondientes
autoconfigure: true Si una clase implementa ciertas interfaces o atributos (#[AsCommand], #[AsEventListener], EventSubscriberInterface…), se le añaden las etiquetas necesarias
App\: + resource Descubrimiento automático: cada clase de src/ se registra como servicio con su FQCN como id
exclude (Opcional) carpetas o ficheros de src/ que no deben ser servicios

💡 Consejo: Registrar todas las clases no tiene coste de rendimiento. Los servicios son privados por defecto y el contenedor elimina de la versión compilada los que nadie usa. Las entidades de Doctrine, aunque estén en src/Entity, no se tratan como servicios (no tiene sentido inyectarlas).


3. Autowiring por tipo

📘 Concepto: Autowiring significa que Symfony mira el tipo de cada argumento del constructor y busca un servicio con ese id (o un alias). Por eso los servicios importantes del framework tienen como alias el nombre de su interfaz: Psr\Log\LoggerInterface, Doctrine\ORM\EntityManagerInterface, Symfony\Component\Mailer\MailerInterface…

¿Cómo sabes qué tipos puedes pedir? Pregúntale al contenedor:

php bin/console debug:autowiring
php bin/console debug:autowiring slug
Autowirable Types
=================

 The following classes & interfaces can be used as type-hints when autowiring:
 (only showing classes/interfaces matching slug)

 Creates a URL-friendly slug from a given string.
 Symfony\Component\String\Slugger\SluggerInterface - alias:slugger

En los controladores, el autowiring funciona tanto en el constructor como en los argumentos de cada acción (eso es lo que has hecho hasta ahora). En el resto de servicios, se usa el constructor.


4. Crear servicios propios

Vamos a sacar la lógica del blog de los controladores a tres servicios: SlugGenerator, MarkdownParser y ArticlePublisher.

4.1 SlugGenerator

Genera un slug único a partir del título, añadiendo -2, -3… si ya existe.

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

namespace App\Service;

use App\Repository\ArticleRepository;
use Symfony\Component\String\Slugger\SluggerInterface;

final readonly class SlugGenerator
{
    public function __construct(
        private SluggerInterface $slugger,
        private ArticleRepository $articles,
    ) {
    }

    public function generate(string $title): string
    {
        $base = $this->slugger->slug($title)->lower()->toString();
        $slug = $base;
        $i = 2;

        while ($this->articles->findOneBy(['slug' => $slug]) !== null) {
            $slug = sprintf('%s-%d', $base, $i++);
        }

        return $slug;
    }
}

¡Y ya está! No hay que tocar services.yaml: está en src/, así que el descubrimiento automático lo registra, y el autowiring resuelve SluggerInterface y ArticleRepository.

💡 Consejo: La promoción de propiedades en el constructor (private SluggerInterface $slugger dentro de los paréntesis) junto con readonly es la forma idiomática de escribir servicios en PHP moderno: menos código y la garantía de que las dependencias no cambian después de construir el objeto.

4.2 MarkdownParser

Convierte el contenido de los artículos (escrito en Markdown) a HTML, cacheando el resultado. Usaremos league/commonmark:

composer require league/commonmark
<?php
// src/Service/MarkdownParser.php
declare(strict_types=1);

namespace App\Service;

use League\CommonMark\CommonMarkConverter;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final readonly class MarkdownParser
{
    private CommonMarkConverter $converter;

    public function __construct(private CacheInterface $cache)
    {
        $this->converter = new CommonMarkConverter([
            'html_input' => 'strip',          // elimina HTML escrito a mano (seguridad)
            'allow_unsafe_links' => false,
        ]);
    }

    public function parse(string $markdown): string
    {
        // La clave depende del contenido: si cambia, se recalcula
        $key = 'markdown_'.hash('xxh128', $markdown);

        return $this->cache->get($key, function (ItemInterface $item) use ($markdown): string {
            $item->expiresAfter(3600);

            return $this->converter->convert($markdown)->getContent();
        });
    }
}

4.3 ArticlePublisher

Encapsula la operación de negocio “publicar un artículo”:

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

namespace App\Service;

use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;

final readonly class ArticlePublisher
{
    public function __construct(
        private EntityManagerInterface $em,
        private SlugGenerator $slugGenerator,
        private LoggerInterface $logger,
    ) {
    }

    public function publish(Article $article, ?\DateTimeImmutable $when = null): void
    {
        if ($article->getPublishedAt() !== null) {
            throw new \LogicException(sprintf('El artículo "%s" ya está publicado.', $article->getTitle()));
        }

        if ($article->getSlug() === null) {
            $article->setSlug($this->slugGenerator->generate($article->getTitle()));
        }

        $article->setPublishedAt($when ?? new \DateTimeImmutable());
        $this->em->flush();

        $this->logger->info('Artículo publicado', [
            'id' => $article->getId(),
            'slug' => $article->getSlug(),
        ]);
    }
}

📘 Concepto: En el módulo 11 este servicio lanzará un evento ArticlePublishedEvent para que otras partes de la aplicación (envío de emails, caché…) reaccionen sin que ArticlePublisher tenga que conocerlas.

4.4 Controladores delgados

Compara un controlador “gordo” con uno que delega en servicios:

// ❌ Controlador gordo: lógica de negocio mezclada con HTTP
#[Route('/admin/articulos/{id}/publicar', name: 'app_admin_article_publish', methods: ['POST'])]
public function publish(Article $article, EntityManagerInterface $em, SluggerInterface $slugger, ArticleRepository $repo): Response
{
    if ($article->getPublishedAt() !== null) { /* ... */ }
    $slug = $slugger->slug($article->getTitle())->lower();
    // bucle de unicidad, fechas, logs, flush...
    return $this->redirectToRoute('app_admin_article_index');
}
// ✅ Controlador delgado: traduce HTTP ↔ servicio
#[Route('/admin/articulos/{id}/publicar', name: 'app_admin_article_publish', methods: ['POST'])]
public function publish(Article $article, ArticlePublisher $publisher): Response
{
    try {
        $publisher->publish($article);
        $this->addFlash('success', 'Artículo publicado.');
    } catch (\LogicException $e) {
        $this->addFlash('warning', $e->getMessage());
    }

    return $this->redirectToRoute('app_admin_article_index', status: Response::HTTP_SEE_OTHER);
}

💡 Consejo: Un buen controlador hace tres cosas: leer la petición, llamar a un servicio y devolver una respuesta. Si tu acción supera las 20-30 líneas, probablemente hay un servicio esperando a nacer. La gran ventaja: la lógica en servicios se puede reutilizar desde un comando de consola (módulo 14), un handler de Messenger (módulo 11) o un test unitario (módulo 13).

4.5 Usar un servicio en Twig

Para usar MarkdownParser desde las plantillas, crea una extensión Twig con atributos:

<?php
// src/Twig/MarkdownExtension.php
declare(strict_types=1);

namespace App\Twig;

use App\Service\MarkdownParser;
use Twig\Attribute\AsTwigFilter;

final readonly class MarkdownExtension
{
    public function __construct(private MarkdownParser $parser)
    {
    }

    #[AsTwigFilter('markdown', isSafe: ['html'])]
    public function markdown(string $content): string
    {
        return $this->parser->parse($content);
    }
}
<div class="article-body">
    {{ article.content|markdown }}
</div>

5. Interfaces y alias

Programar contra interfaces permite cambiar la implementación sin tocar a quien la usa. Extraemos una interfaz del parser:

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

namespace App\Service;

interface MarkdownParserInterface
{
    public function parse(string $markdown): string;
}
// src/Service/MarkdownParser.php
final readonly class MarkdownParser implements MarkdownParserInterface
{
    // ...
}

Si solo una clase implementa la interfaz, Symfony crea el alias automáticamente y puedes pedir MarkdownParserInterface en cualquier constructor:

public function __construct(private MarkdownParserInterface $parser) {}

📘 Concepto: Un alias es otro nombre para un servicio existente. MarkdownParserInterface → App\Service\MarkdownParser. El autowiring busca el tipo del argumento entre los ids y alias del contenedor.


6. Varias implementaciones del mismo tipo

¿Y si hay dos implementaciones? Por ejemplo, un CommonMarkParser y un ParsedownParser (más rápido, menos completo). El autowiring ya no sabe cuál elegir y lanza un error explicativo. Tienes varias opciones.

6.1 Implementación por defecto con #[AsAlias]

use Symfony\Component\DependencyInjection\Attribute\AsAlias;

#[AsAlias(MarkdownParserInterface::class)]
final readonly class CommonMarkParser implements MarkdownParserInterface
{
    // ...
}

Ahora MarkdownParserInterface se resuelve con CommonMarkParser, salvo que pidas otra explícitamente.

6.2 Alias con nombre y #[Target]

#[AsAlias(MarkdownParserInterface::class, target: 'fastParser')]
final readonly class ParsedownParser implements MarkdownParserInterface
{
    // ...
}
use Symfony\Component\DependencyInjection\Attribute\Target;

final readonly class CommentRenderer
{
    public function __construct(
        // Los comentarios son cortos: usamos el parser rápido
        #[Target('fastParser')]
        private MarkdownParserInterface $parser,
    ) {
    }
}

⚠️ Importante: Antiguamente bastaba con llamar al argumento igual que el alias (MarkdownParserInterface $fastParser) para obtener esa implementación (“named autowiring” por nombre de argumento). Ese mecanismo implícito es frágil (renombrar una variable cambia el servicio inyectado) y Symfony 8.1 lo marca como obsoleto. Usa siempre #[Target].

6.3 #[Autowire] con un servicio concreto

use Symfony\Component\DependencyInjection\Attribute\Autowire;

public function __construct(
    #[Autowire(service: ParsedownParser::class)]
    private MarkdownParserInterface $parser,
) {
}

6.4 Equivalente en YAML

# config/services.yaml
services:
    # ...
    # implementación por defecto
    App\Service\MarkdownParserInterface: '@App\Service\CommonMarkParser'

    # alias con nombre (usable con #[Target('fastParser')])
    App\Service\MarkdownParserInterface $fastParser: '@App\Service\ParsedownParser'

    # o configurar el argumento de un servicio concreto
    App\Service\CommentRenderer:
        arguments:
            $parser: '@App\Service\ParsedownParser'
Técnica Dónde se declara Cuándo usarla
Alias automático Nada (una sola implementación) El caso más común
#[AsAlias(Interfaz::class)] En la implementación Fijar la implementación por defecto
#[AsAlias(..., target: 'x')] + #[Target('x')] Implementación + consumidor Elegir entre varias por nombre
#[Autowire(service: ...)] En el consumidor Caso puntual
YAML (arguments, alias) services.yaml Configuración centralizada

7. Parámetros

📘 Concepto: Un parámetro es un valor de configuración (cadena, número, booleano, array) almacenado en el contenedor. Se referencian con la sintaxis %nombre%.

# config/services.yaml
parameters:
    app.articles_per_page: 10
    app.blog_name: 'Blog de Symfony'
    app.uploads_dir: '%kernel.project_dir%/public/uploads'
    app.admin_emails: ['admin@blog.test', 'editor@blog.test']

Parámetros del kernel

Parámetro Valor
%kernel.project_dir% Raíz del proyecto
%kernel.environment% dev, prod o test
%kernel.debug% true/false
%kernel.cache_dir% var/cache/<entorno>
%kernel.logs_dir% var/log
%kernel.secret% Valor de APP_SECRET

Inyectar parámetros

use Symfony\Component\DependencyInjection\Attribute\Autowire;

final readonly class BlogSettings
{
    public function __construct(
        #[Autowire(param: 'app.articles_per_page')]
        public int $perPage,

        #[Autowire('%app.blog_name%')]          // forma equivalente
        public string $blogName,

        #[Autowire('%kernel.project_dir%/var/exports')] // se pueden combinar con texto
        public string $exportDir,
    ) {
    }
}

O, si el mismo argumento aparece en muchos servicios, usa bind en _defaults:

services:
    _defaults:
        autowire: true
        autoconfigure: true
        bind:
            # cualquier argumento llamado $blogName recibe este valor
            string $blogName: '%app.blog_name%'

En controladores que extienden AbstractController también puedes leer un parámetro con $this->getParameter('app.blog_name'), aunque inyectarlo es más limpio.

⚠️ Importante: Los parámetros son estáticos: se fijan al compilar el contenedor. Si cambias services.yaml en producción, tendrás que vaciar la caché (php bin/console cache:clear). Lo que cambia entre máquinas (contraseñas, URLs) va en variables de entorno.


8. Variables de entorno

8.1 Los ficheros .env

Ya los conociste en el módulo 01. Repaso del orden de carga (los posteriores sobrescriben a los anteriores):

Fichero ¿Se sube a Git? Uso
.env Sí Valores por defecto para todos
.env.local No Tus valores locales (BD, claves de prueba)
.env.<entorno> Sí Valores por defecto de un entorno (.env.test)
.env.<entorno>.local No Valores locales de un entorno
Variables reales del sistema — Siempre ganan (producción, Docker)

8.2 Usarlas en la configuración

# .env
BLOG_ARTICLES_PER_PAGE=10
BLOG_MAINTENANCE=false
BLOG_SOCIAL_LINKS='{"mastodon":"https://mastodon.social/@blog","github":"https://github.com/blog"}'
# config/services.yaml
parameters:
    app.articles_per_page: '%env(int:BLOG_ARTICLES_PER_PAGE)%'
    app.maintenance: '%env(bool:BLOG_MAINTENANCE)%'
    app.social_links: '%env(json:BLOG_SOCIAL_LINKS)%'

O directamente en un servicio:

public function __construct(
    #[Autowire(env: 'bool:BLOG_MAINTENANCE')]
    private bool $maintenance,
) {
}

8.3 Procesadores de variables de entorno

Las variables de entorno siempre son cadenas. Los procesadores las convierten o transforman:

Procesador Ejemplo Resultado
int: %env(int:BLOG_ARTICLES_PER_PAGE)% 10 (entero)
float: %env(float:TAX_RATE)% 0.21
bool: %env(bool:BLOG_MAINTENANCE)% false ("true", "1", "on" → true)
json: %env(json:BLOG_SOCIAL_LINKS)% Array PHP
csv: %env(csv:ADMIN_EMAILS)% Array a partir de a,b,c
resolve: %env(resolve:UPLOADS_DIR)% Sustituye %parametros% dentro del valor
trim: %env(trim:API_KEY)% Quita espacios
file: %env(file:CERT_PATH)% Contenido del fichero indicado
default: %env(default:app.fallback:OPTIONAL_VAR)% Usa el parámetro si la variable no existe o está vacía
enum: %env(enum:App\Enum\Theme:BLOG_THEME)% Caso de un enum respaldado

Se pueden encadenar: %env(json:file:CONFIG_FILE)% lee el fichero y decodifica su JSON.

# .env
UPLOADS_DIR=%kernel.project_dir%/public/uploads
parameters:
    app.uploads_dir: '%env(resolve:UPLOADS_DIR)%'

8.4 Secretos

Las contraseñas de producción (SMTP, API de pago) no deberían estar en texto plano en el servidor. Symfony incluye una bóveda de secretos cifrada:

# Genera el par de claves (una vez por entorno)
php bin/console secrets:generate-keys
php bin/console secrets:generate-keys --env=prod

# Guarda un secreto (te lo pide de forma oculta)
php bin/console secrets:set MAILER_PASSWORD --env=prod

# Lista los secretos (y muestra su valor con --reveal)
php bin/console secrets:list --reveal

Los secretos se usan exactamente igual que una variable de entorno: %env(MAILER_PASSWORD)%. Los ficheros cifrados se suben a Git; la clave privada de producción no. Lo veremos en detalle en el módulo 15.


9. Servicios etiquetados y atributos de configuración

9.1 Etiquetas: agrupar servicios

📘 Concepto: Una etiqueta (tag) es una marca que se pone a un servicio para que otro lo encuentre. Así funcionan internamente los comandos (console.command), los listeners (kernel.event_listener) o las extensiones de Twig (twig.extension). La autoconfiguración pone esas etiquetas por ti.

Puedes usar el mismo mecanismo en tu código. Ejemplo: exportar artículos en varios formatos (CSV, JSON, Markdown…), donde cada formato es una clase y quieres poder añadir formatos nuevos sin tocar el código existente.

<?php
// src/Export/ArticleExporterInterface.php
declare(strict_types=1);

namespace App\Export;

use App\Entity\Article;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('app.article_exporter')]
interface ArticleExporterInterface
{
    public function format(): string;

    /** @param Article[] $articles */
    public function export(array $articles): string;
}

Toda clase que implemente la interfaz recibirá automáticamente la etiqueta app.article_exporter:

<?php
// src/Export/JsonArticleExporter.php
declare(strict_types=1);

namespace App\Export;

use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;

#[AsTaggedItem(index: 'json', priority: 10)]
final class JsonArticleExporter implements ArticleExporterInterface
{
    public function format(): string
    {
        return 'json';
    }

    public function export(array $articles): string
    {
        return json_encode(array_map(static fn ($a) => [
            'title' => $a->getTitle(),
            'slug' => $a->getSlug(),
            'publishedAt' => $a->getPublishedAt()?->format(\DATE_ATOM),
        ], $articles), \JSON_PRETTY_PRINT | \JSON_THROW_ON_ERROR);
    }
}
<?php
// src/Export/CsvArticleExporter.php
declare(strict_types=1);

namespace App\Export;

use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;

#[AsTaggedItem(index: 'csv')]
final class CsvArticleExporter implements ArticleExporterInterface
{
    public function format(): string
    {
        return 'csv';
    }

    public function export(array $articles): string
    {
        $handle = fopen('php://temp', 'r+');
        fputcsv($handle, ['title', 'slug', 'publishedAt'], escape: '');
        foreach ($articles as $a) {
            fputcsv($handle, [$a->getTitle(), $a->getSlug(), $a->getPublishedAt()?->format('Y-m-d')], escape: '');
        }
        rewind($handle);

        return stream_get_contents($handle);
    }
}

9.2 #[AutowireIterator]: recibirlos todos

<?php
// src/Export/ExportManager.php
declare(strict_types=1);

namespace App\Export;

use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

final readonly class ExportManager
{
    /**
     * @param iterable<string, ArticleExporterInterface> $exporters
     */
    public function __construct(
        #[AutowireIterator('app.article_exporter')]
        private iterable $exporters,
    ) {
    }

    /** @return list<string> */
    public function availableFormats(): array
    {
        $formats = [];
        foreach ($this->exporters as $exporter) {
            $formats[] = $exporter->format();
        }

        return $formats; // ['json', 'csv'] (ordenados por prioridad)
    }

    public function export(string $format, array $articles): string
    {
        foreach ($this->exporters as $exporter) {
            if ($exporter->format() === $format) {
                return $exporter->export($articles);
            }
        }

        throw new \InvalidArgumentException(sprintf('Formato "%s" no soportado.', $format));
    }
}

💡 Consejo: Si solo vas a usar uno de los servicios cada vez, #[AutowireLocator('app.article_exporter')] inyecta un service locator: un mini-contenedor del que obtienes el servicio por su índice ($locator->get('csv')) y que solo instancia el que pidas.

Ahora, para añadir un exportador a Markdown basta con crear una clase que implemente la interfaz. Ni ExportManager ni services.yaml cambian: es el principio abierto/cerrado en acción.

9.3 Servicios según el entorno: #[When]

use Symfony\Component\DependencyInjection\Attribute\When;

// Solo se registra en dev: por ejemplo, un servicio con datos extra de depuración
#[When(env: 'dev')]
final class DevToolbarInfoCollector
{
    // ...
}

También existen #[WhenNot(env: 'prod')] y #[Exclude] (la clase no se registra como servicio).

9.4 Servicios lazy

Un servicio lazy no se construye hasta que se llama a uno de sus métodos. Útil para servicios caros (conexiones a APIs externas) que solo se usan en algunas ramas del código.

use Symfony\Component\DependencyInjection\Attribute\Lazy;

#[Lazy]
final class SearchEngineClient
{
    public function __construct()
    {
        // conexión costosa a un motor de búsqueda...
    }
}

O solo en un punto concreto de inyección:

use Symfony\Component\DependencyInjection\Attribute\Lazy;

public function __construct(
    #[Lazy]
    private SearchEngineClient $search,
) {
}

📘 Concepto: Con PHP 8.4 o superior, Symfony implementa los servicios lazy con los objetos lazy nativos de PHP, por lo que funcionan también con clases final y readonly.

9.5 Servicios públicos y privados

Todos los servicios son privados por defecto: no puedes hacer $container->get(SlugGenerator::class). Esto es intencionado: obliga a usar inyección de dependencias y permite al contenedor optimizar y eliminar los que no se usan.

use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;

#[Autoconfigure(public: true)] // casi nunca lo necesitarás
final class LegacyBridge {}

⚠️ Importante: Si te descubres buscando cómo hacer $this->container->get(...) en un controlador o servicio, para: estás ante el antipatrón service locator. Pide el servicio por constructor o como argumento de la acción. En los tests (módulo 13) existe static::getContainer(), que sí da acceso a servicios privados.


10. Depurar el contenedor

Comando Para qué
php bin/console debug:autowiring Tipos que puedes pedir por autowiring
php bin/console debug:autowiring markdown Filtra por texto
php bin/console debug:container Todos los servicios registrados
php bin/console debug:container 'App\Service\ArticlePublisher' Detalles de un servicio (clase, argumentos, etiquetas)
php bin/console debug:container --tag=app.article_exporter Servicios con una etiqueta
php bin/console debug:container --parameters Todos los parámetros
php bin/console debug:container --env-vars Variables de entorno usadas y sus valores
php bin/console lint:container Comprueba que los argumentos encajan con los tipos
$ php bin/console debug:container 'App\Service\ArticlePublisher'

Information for Service "App\Service\ArticlePublisher"
======================================================

 ---------------- -----------------------------------
  Option           Value
 ---------------- -----------------------------------
  Service ID       App\Service\ArticlePublisher
  Class            App\Service\ArticlePublisher
  Tags             -
  Public           no
  Shared           yes
  Abstract         no
  Autowired        yes
  Autoconfigured   yes
 ---------------- -----------------------------------

11. Configuración de bundles por entorno

Cada bundle se configura con un fichero en config/packages/ cuyo nombre suele coincidir con su clave raíz (doctrine.yaml, twig.yaml, framework.yaml, monolog.yaml…). Para ajustar algo solo en un entorno, usa la sintaxis when@<entorno> en el mismo fichero:

# config/packages/twig.yaml
twig:
    file_name_pattern: '*.twig'
    form_themes: ['bootstrap_5_layout.html.twig']
    globals:
        blog_name: '%app.blog_name%'

when@test:
    twig:
        strict_variables: true
# config/packages/framework.yaml (fragmento)
framework:
    secret: '%env(APP_SECRET)%'
    session: true

when@test:
    framework:
        test: true
        session:
            storage_factory_id: session.storage.factory.mock_file

Comandos útiles para explorar la configuración:

# Configuración actual (ya fusionada) de un bundle
php bin/console debug:config twig

# Todas las opciones posibles con sus valores por defecto
php bin/console config:dump-reference twig

💡 Consejo: when@dev, when@test y when@prod también funcionan dentro de services.yaml. Es la forma recomendada de tener servicios o parámetros distintos por entorno, en lugar de crear carpetas config/packages/dev/.


12. Buenas prácticas y errores comunes

💡 Consejo: Buenas prácticas con servicios:

  • Controladores delgados: la lógica de negocio va en servicios con nombres de dominio (ArticlePublisher, no ArticleHelper).
  • Inyecta por constructor con propiedades private readonly.
  • Depende de interfaces cuando prevés varias implementaciones o quieres sustituirlas en tests.
  • Los servicios deben ser sin estado (stateless): no guardes datos de una petición en propiedades, porque la misma instancia se reutiliza.
  • Prefiere atributos (#[Autowire], #[AsAlias], #[Target]) junto a la clase; usa services.yaml para configuración global.
Error Causa Solución
“Cannot autowire argument $x… it references interface X but no such service exists” Ninguna clase implementa la interfaz o hay varias Crea la implementación, usa #[AsAlias] o #[Target]
“Cannot autowire argument $perPage… type int” Los escalares no se autoconectan #[Autowire(param: '...')] o bind
“The service X has a dependency on a non-existent parameter” Parámetro mal escrito o no definido Revisa parameters: y debug:container --parameters
“Environment variable not found” Falta la variable en .env Defínela en .env (valor por defecto) y en .env.local
“Circular reference detected” A necesita B y B necesita A Rediseña: extrae la parte común a un tercer servicio, o usa un evento
Un cambio en services.yaml no se aplica en prod Contenedor compilado en caché php bin/console cache:clear --env=prod

13. Ejercicios

Ejercicio 1: SlugGenerator

Crea el servicio SlugGenerator de la sección 4.1 y úsalo en el controlador de creación de artículos y en el de categorías (adapta el servicio para recibir el repositorio adecuado o crea un método genérico). Comprueba con debug:container que está registrado.

Ejercicio 2: Filtro markdown

Instala league/commonmark, crea MarkdownParser con caché y la extensión Twig con el filtro markdown. Escribe un artículo con títulos, listas y un bloque de código y verifica que se muestra formateado. Comprueba en el Profiler (panel Cache) que la segunda visita usa la caché.

Ejercicio 3: Controlador delgado

Crea ArticlePublisher y una acción POST /admin/articulos/{id}/publicar que lo use. La acción no debe tener más de 12 líneas. Publica un artículo dos veces y comprueba que la segunda vez aparece el aviso.

Ejercicio 4: Configuración por parámetros y entorno

Define app.articles_per_page a partir de la variable BLOG_ARTICLES_PER_PAGE con el procesador int:. Úsala en la paginación del módulo 06. Cambia el valor en .env.local y comprueba que la portada muestra un número distinto de artículos sin tocar el código.

Ejercicio 5: Dos parsers

Crea una segunda implementación PlainTextParser de MarkdownParserInterface que solo escape el HTML y convierta saltos de línea en <br>. Haz que CommonMarkParser sea la implementación por defecto con #[AsAlias] y que el renderizado de comentarios use PlainTextParser mediante #[Target].

Ejercicio 6: Exportadores etiquetados

Implementa ArticleExporterInterface con #[AutoconfigureTag], los exportadores JSON y CSV, y ExportManager con #[AutowireIterator]. Crea la ruta /admin/exportar/{format} que devuelva un fichero descargable. Luego añade un exportador Markdown sin modificar ninguna clase existente.

Ejercicio 7: Investiga el contenedor

Ejecuta debug:autowiring y anota cinco servicios del framework que no hayas usado todavía. Usa debug:container --tag=kernel.event_listener para ver cuántos listeners hay registrados (los estudiarás en el módulo 11) y debug:config framework para ver la configuración actual.


Resumen

Concepto Sintaxis / herramienta
Servicio Objeto que hace un trabajo; uno por petición (compartido)
Registro automático App\: resource: '../src/' en services.yaml
Autowiring autowire: true: inyección según el tipo del argumento
Autoconfiguración autoconfigure: true: etiquetas automáticas
Inyección por constructor public function __construct(private readonly X $x) {}
Implementación por defecto #[AsAlias(Interfaz::class)]
Elegir implementación #[AsAlias(..., target: 'nombre')] + #[Target('nombre')]
Servicio concreto #[Autowire(service: Clase::class)]
Parámetros parameters: + %nombre% / #[Autowire(param: 'nombre')]
Variables de entorno %env(int:VAR)%, #[Autowire(env: 'bool:VAR')]
Secretos secrets:set, secrets:list --reveal
Etiquetar #[AutoconfigureTag('tag')], #[AsTaggedItem(index:, priority:)]
Recibir etiquetados #[AutowireIterator('tag')] / #[AutowireLocator('tag')]
Por entorno #[When(env: 'dev')], when@prod: en YAML
Lazy #[Lazy] en la clase o en el argumento
Depurar debug:autowiring, debug:container, debug:config, lint:container