Factories, Decorators and Lazy Services

Three ways to control construction: build a service through a factory, wrap one you do not own, and defer an expensive one until it is used.

Syntax#[AsDecorator(decorates: CsvExporter::class)] / factory: ['@App\Export\ExporterFactory', 'createCsvExporter'] / #[Lazy]

Most services are built by calling their constructor with injected arguments. Three options cover the cases where that is not enough.

A factory builds it

When creation needs logic — a choice between two classes, a method call after construction — point the definition at a factory: factory: ['@App\Export\ExporterFactory', 'createCsvExporter']. The container calls that method and registers whatever it returns. A static method on the service's own class has a shorthand: constructor: 'create' is the same definition as factory: [null, 'create'].

A decorator wraps it

Decorating replaces a service id with your class while keeping the original available under the id .inner. Your class takes the original as an argument, adds behaviour, and delegates the rest. #[AsDecorator(decorates: CsvExporter::class)] with #[AutowireDecorated] on the argument is the attribute form; decorates: plus arguments: ['@.inner'] is the configuration longhand. With several decorators on one service, a higher priority is applied earlier, so it ends up further inside.

Lazy means built on first use

#[Lazy] on a class is the same as #[Autoconfigure(lazy: true)] and the same as lazy: true in configuration. The container injects a placeholder that constructs the real service the first time something touches it. On PHP 8.4 and later this uses native lazy objects, so final and readonly classes work; on older versions they did not. #[Lazy] on a single argument makes only that injection lazy.

What trips people up

Lazy is for a service that is expensive to build and rarely used. It is not a fix for a circular dependency that should be untangled.

Example

Example · php
// src/Export/CountingExporter.php
declare(strict_types=1);

namespace App\Export;

use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;

// this class now answers to the id App\Export\CsvExporter
#[AsDecorator(decorates: CsvExporter::class)]
final class CountingExporter implements ExporterInterface
{
    private int $calls = 0;

    public function __construct(
        // the service being decorated; with attributes the argument
        // may be named anything
        #[AutowireDecorated]
        private ExporterInterface $inner,
    ) {
    }

    public function format(): string
    {
        return $this->inner->format();
    }

    public function supports(string $format): bool
    {
        return $this->inner->supports($format);
    }

    public function export(array $rows): string
    {
        ++$this->calls;

        return $this->inner->export($rows);
    }

    public function calls(): int
    {
        return $this->calls;
    }
}

// $exporter->export([['id' => 1]]);
// $exporter->export([['id' => 2]]);
// $exporter->calls()
// Output: 2

When to use it

  • Build a client whose configuration depends on the current locale or environment.
  • Add logging, counting or caching around a service from a third-party package without editing it.
  • Keep a request fast when it injects a service that loads fonts, templates or a large configuration file it may never use.

More examples

A factory that decides how to build the service

Reach for a factory when the class alone cannot know how it should be built. Arguments listed on the definition are passed to the factory method, and they are autowired like any other arguments. An invokable factory works too: factory: '@App\Export\ExporterFactory' calls __invoke().

Example · php
// src/Export/ExporterFactory.php
declare(strict_types=1);

namespace App\Export;

final class ExporterFactory
{
    public function __construct(
        private string $defaultDelimiter = ',',
    ) {
    }

    // the container calls this instead of new CsvExporter(...)
    public function createCsvExporter(string $locale): CsvExporter
    {
        // spreadsheets in some locales expect a semicolon
        $delimiter = \in_array($locale, ['de', 'fr'], true) ? ';' : $this->defaultDelimiter;

        return new CsvExporter($delimiter);
    }
}

// (new ExporterFactory())->createCsvExporter('de')->export([['a', 'b']])
// Output: a;b

// (new ExporterFactory())->createCsvExporter('en')->export([['a', 'b']])
// Output: a,b

The configuration longhand for all three

Use the configuration form when the service comes from a package you cannot add attributes to, or when the wiring is a deployment decision rather than a property of the class. Naming an interface after lazy restricts the placeholder to that interface, which is how the technique still works for code that must run on PHP below 8.4.

Example · yaml
# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    # built by a factory method instead of its own constructor
    App\Export\CsvExporter:
        factory: ['@App\Export\ExporterFactory', 'createCsvExporter']
        arguments: ['%kernel.default_locale%']

    # longhand for #[AsDecorator] + #[AutowireDecorated]
    App\Export\CountingExporter:
        decorates: App\Export\CsvExporter
        decoration_priority: 10
        arguments: ['@.inner']

    # longhand for #[Lazy] on the class
    App\Report\PdfRenderer:
        lazy: true

    # a placeholder implementing only this interface, not the whole class
    App\Report\ChartRenderer:
        lazy: 'App\Report\RendererInterface'

Deferring one expensive dependency

#[Lazy] on an argument is narrower than marking the whole service lazy: other consumers of PdfRenderer still receive the real object. The same thing spelled out is #[Autowire(service: PdfRenderer::class, lazy: true)].

Example · php
// src/Report/ReportMailer.php
declare(strict_types=1);

namespace App\Report;

use Symfony\Component\DependencyInjection\Attribute\Lazy;

final class ReportMailer
{
    public function __construct(
        // the renderer loads fonts and templates when it is constructed,
        // so build it only if a report is actually attached
        #[Lazy]
        private PdfRenderer $renderer,
    ) {
    }

    public function subjectFor(string $period, bool $withAttachment): string
    {
        if (!$withAttachment) {
            // nothing touches $this->renderer, so it is never built
            return sprintf('Report for %s', $period);
        }

        return sprintf('Report for %s (%d pages)', $period, $this->renderer->pageCount());
    }
}

// $mailer->subjectFor('2026-09', false)
// Output: Report for 2026-09

Discussion

  • Be the first to comment on this lesson.