DEV Community

Cover image for Symfony DependencyInjection for Hexagonal: Autowiring the Whole Domain
Gabriel Anhaia
Gabriel Anhaia

Posted on

Symfony DependencyInjection for Hexagonal: Autowiring the Whole Domain


Open any hexagonal-architecture tutorial written before 2018 and you will find an appendix that nobody enjoys: the XML container config. Two screens of <service id="..."> for a project that has six classes. The complaint that killed hexagonal for a lot of Symfony shops was never about ports or adapters. It was about that file.

Modern Symfony fixed it years ago and most teams never went back to update their mental model. Autowiring plus autoconfigure plus a small bind block reduce services.yaml to under twenty lines for an entire bounded context. Your OrderRepository interface lives in App\Order\Domain\Port and its Doctrine implementation lives in App\Order\Infrastructure\Persistence. The container connects them without you naming either.

Three knobs in services.yaml do most of the work. One attribute handles the ambiguous case. The rest of the post is which knob is which and where the rule breaks.

The starting layout

Take a single bounded context, kept in one tree under src/Order/:

src/Order/
├── Domain/
│   ├── Order.php
│   ├── OrderId.php
│   └── Port/
│       ├── OrderRepository.php
│       ├── Clock.php
│       └── EventBus.php
├── Application/
│   └── UseCase/
│       ├── PlaceOrder.php
│       └── CancelOrder.php
└── Infrastructure/
    ├── Persistence/
    │   ├── DoctrineOrderRepository.php
    │   └── InMemoryOrderRepository.php
    ├── Clock/
    │   └── SystemClock.php
    ├── EventBus/
    │   ├── SymfonyMessengerEventBus.php
    │   └── NullEventBus.php
    └── Http/
        └── PlaceOrderController.php
Enter fullscreen mode Exit fullscreen mode

The Domain layer depends on nothing in Infrastructure. The Application layer depends on Domain. Infrastructure depends on both and on the framework. PHP 8.3 attribute syntax across the board.

A port looks like this:

<?php

declare(strict_types=1);

namespace App\Order\Domain\Port;

use App\Order\Domain\Order;
use App\Order\Domain\OrderId;

interface OrderRepository
{
    public function save(Order $order): void;

    public function findById(OrderId $id): ?Order;
}
Enter fullscreen mode Exit fullscreen mode

A use case consumes ports:

<?php

declare(strict_types=1);

namespace App\Order\Application\UseCase;

use App\Order\Domain\Order;
use App\Order\Domain\Port\Clock;
use App\Order\Domain\Port\EventBus;
use App\Order\Domain\Port\OrderRepository;

final readonly class PlaceOrder
{
    public function __construct(
        private OrderRepository $orders,
        private Clock $clock,
        private EventBus $events,
    ) {}

    public function __invoke(PlaceOrderInput $in): OrderId
    {
        $order = Order::place(
            customerId: $in->customerId,
            items: $in->items,
            placedAt: $this->clock->now(),
        );

        $this->orders->save($order);
        $this->events->publish(...$order->pullEvents());

        return $order->id();
    }
}
Enter fullscreen mode Exit fullscreen mode

Nothing in PlaceOrder knows that OrderRepository will turn out to be Doctrine, that Clock will be the system clock, or that EventBus will be Messenger. The constructor asks for interfaces. The container's job is to hand back the right concrete classes when the controller resolves PlaceOrder.

hexagonal layers wired through the Symfony container

The three knobs in services.yaml

Everything in this section sits in config/services.yaml. A fresh Symfony project ships with a sensible default. Here is the version that gives you a hexagonal codebase for almost nothing:

services:
    _defaults:
        autowire: true
        autoconfigure: true
        public: false

    App\:
        resource: '../src/'
        exclude:
            - '../src/Kernel.php'
            - '../src/**/Domain/**/{Order,OrderId,Money}.php'
            - '../src/**/Domain/Port/**'

    App\Order\Application\UseCase\:
        resource: '../src/Order/Application/UseCase/'

    App\Order\Infrastructure\Persistence\DoctrineOrderRepository: ~
    App\Order\Domain\Port\OrderRepository:
        alias: App\Order\Infrastructure\Persistence\DoctrineOrderRepository
Enter fullscreen mode Exit fullscreen mode

Four blocks, each worth a beat.

autowire: true

autowire reads the constructor signature of a service and looks up each parameter type in the container. When PlaceOrder asks for OrderRepository, Symfony searches its registry for a service whose ID is exactly that fully qualified interface name. If it finds one, it injects it. If it finds nothing, the build fails at compile time. That is the property you want.

You never write arguments: ['@app.order_repository']. The type system is the wiring spec.

autoconfigure: true

autoconfigure reads attributes and interfaces on each class and tags it accordingly. The cases that matter for a hexagonal app:

  • A class implementing Symfony\Component\Messenger\Handler\MessageHandlerInterface gets tagged for the message bus.
  • A class with #[AsCommand] gets registered as a console command.
  • A class with #[AsController] gets routed.
  • A class with #[AsMessageHandler] gets wired to the right transport.

Your inbound adapters self-register based on what they say they are. No tags: list in YAML.

The PSR-4 import block (App\: resource: '../src/')

This is the line that registers your whole codebase as services. Symfony walks src/, treats every class it finds as a service candidate, and uses its fully qualified class name as the service ID. That is why OrderRepository can resolve to DoctrineOrderRepository by alias: both already exist as service IDs.

The exclude list matters. Pure domain types like Order, OrderId, and Money must NOT be services. They are value objects and entities; they get constructed by your code, not by the container. Excluding Domain/Port/** is also worth doing: interfaces alone are not services, and trying to autoregister them generates noise.

The alias

App\Order\Domain\Port\OrderRepository:
    alias: App\Order\Infrastructure\Persistence\DoctrineOrderRepository
Enter fullscreen mode Exit fullscreen mode

This is the entire hex wiring, expressed in two lines per port. "When something asks for the interface, give it this implementation." Aliases compile away. There is no indirection cost at runtime.

You repeat this pattern once per port. Three ports, three aliases. That is the entire integration layer between your domain and your framework.

Two implementations, one port: the ambiguity case

The pattern above works as long as exactly one class implements each interface. Reality intrudes on day two, when you add InMemoryOrderRepository for tests:

<?php

declare(strict_types=1);

namespace App\Order\Infrastructure\Persistence;

use App\Order\Domain\Order;
use App\Order\Domain\OrderId;
use App\Order\Domain\Port\OrderRepository;

final class InMemoryOrderRepository implements OrderRepository
{
    /** @var array<string, Order> */
    private array $orders = [];

    public function save(Order $order): void
    {
        $this->orders[$order->id()->toString()] = $order;
    }

    public function findById(OrderId $id): ?Order
    {
        return $this->orders[$id->toString()] ?? null;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now two services implement OrderRepository. The alias is unambiguous (OrderRepository still points at the Doctrine one), but if anything autowires InMemoryOrderRepository directly, autoconfigure will not silently rewire the port.

The harder case is when two implementations of the same interface need to coexist in production. For example, a NullEventBus for the request-time path and a SymfonyMessengerEventBus for the async path. The #[Autowire] attribute resolves it at the consumer:

<?php

declare(strict_types=1);

namespace App\Order\Application\UseCase;

use App\Order\Domain\Order;
use App\Order\Domain\Port\Clock;
use App\Order\Domain\Port\EventBus;
use App\Order\Domain\Port\OrderRepository;
use App\Order\Infrastructure\EventBus\SymfonyMessengerEventBus;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final readonly class PlaceOrder
{
    public function __construct(
        private OrderRepository $orders,
        private Clock $clock,
        #[Autowire(service: SymfonyMessengerEventBus::class)]
        private EventBus $events,
    ) {}

    // __invoke unchanged
}
Enter fullscreen mode Exit fullscreen mode

#[Autowire(service: ...)] overrides the container's default resolution for a single parameter. The interface stays as the type hint, so the domain has no idea which adapter it got. The use case still mocks cleanly in unit tests because the type is still the port.

For the inverse case (different instances of the same class with different config) #[Autowire(param: 'app.feature_flag.enabled')] injects a parameter value, and #[Autowire(env: 'STRIPE_API_KEY')] injects an env variable straight into the constructor. The consumer says what it needs; the YAML stays out of it.

autowire attribute resolving an ambiguous port to a specific adapter

bind: parameters that aren't classes

Domain code sometimes wants primitives. A retry policy needs a max attempt count. A token issuer needs a secret. Those are not service IDs, and autowire cannot guess them from a type. This is where bind earns its place:

services:
    _defaults:
        autowire: true
        autoconfigure: true
        public: false
        bind:
            string $stripeApiKey: '%env(STRIPE_API_KEY)%'
            int $defaultRetryAttempts: 3
            Psr\Log\LoggerInterface $auditLogger: '@monolog.logger.audit'
Enter fullscreen mode Exit fullscreen mode

Any constructor parameter named $stripeApiKey of type string now gets the env value. Any LoggerInterface $auditLogger gets the audit channel logger. These bindings cascade into the whole container, so the same parameter name means the same value everywhere. Useful, and a trap if you reuse names.

The same effect with attributes, scoped to one consumer:

public function __construct(
    #[Autowire('%env(STRIPE_API_KEY)%')]
    private readonly string $apiKey,
    #[Autowire(service: 'monolog.logger.audit')]
    private readonly LoggerInterface $log,
) {}
Enter fullscreen mode Exit fullscreen mode

Bindings in YAML are the right tool when the parameter is shared across many services. The attribute is the right tool when only one use case needs it. Mixing both is fine; preferring the attribute when in doubt keeps the YAML small.

Tagged iterators: when you have N adapters of one port

A common hexagonal pattern is a port with multiple implementations, all of which run. Domain events go to logging, metrics, and an outbox. Validators apply in sequence. The port becomes a list:

interface OrderEventListener
{
    public function on(OrderPlaced $event): void;
}
Enter fullscreen mode Exit fullscreen mode

Symfony's #[AutoconfigureTag] plus #[TaggedIterator] does the rest:

<?php

namespace App\Order\Domain\Port;

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('app.order.event_listener')]
interface OrderEventListener
{
    public function on(OrderPlaced $event): void;
}
Enter fullscreen mode Exit fullscreen mode

Any class implementing the interface is tagged automatically because autoconfigure: true is on. The consumer pulls the lot:

use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

final readonly class OrderEventDispatcher
{
    public function __construct(
        #[AutowireIterator('app.order.event_listener')]
        private iterable $listeners,
    ) {}

    public function dispatch(OrderPlaced $event): void
    {
        foreach ($this->listeners as $listener) {
            $listener->on($event);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

No services.yaml edit when a new listener gets added. Add a class that implements the interface and it joins the iteration. That is the rule.

The one place to drop to explicit syntax

There is one case where the autowiring pipeline is worse than just writing the definition: when the same class produces two different services with different state. Two HTTP clients pointed at different base URLs. Two database connections. Two queue workers with different topics.

services:
    App\Shared\Http\InternalApiClient:
        arguments:
            $baseUrl: '%env(INTERNAL_API_URL)%'
            $timeout: 5

    App\Shared\Http\PartnerApiClient:
        class: App\Shared\Http\HttpClient
        arguments:
            $baseUrl: '%env(PARTNER_API_URL)%'
            $timeout: 30
Enter fullscreen mode Exit fullscreen mode

Trying to express this with attributes ends up worse than the YAML. Take the YAML.

What this buys you

Lines of YAML in a clean Symfony hexagonal project, per bounded context:

  • 1 _defaults block (shared).
  • 1 PSR-4 resource block (shared).
  • 1 use-case resource block (shared, or absorbed into the broader one).
  • 2 lines per port (alias).

Adding a port and an adapter is two lines. Replacing a Doctrine adapter with an in-memory one for a test environment is one line in a services_test.yaml override that changes the alias target. Adding a third adapter that runs alongside the others is zero lines: the tag picks it up.

This is the version of hexagonal that scales for a Symfony team. The framework finally pays its rent: it does the wiring you would otherwise write by hand, and it gets out of the way.

When something feels hard, the rule is the same one that applies to the architecture itself: push the complication outward. Awkward attribute syntax in a use case is a smell. The use case is asking for something framework-shaped. Move the construction to a factory in Infrastructure, register the factory, return a clean object the domain understands.


If this was useful

This is one chapter's worth of the wiring work in Decoupled PHP. The rest of the book is the same level of attention applied to ports, use cases, transactions, error translation across layers, and migrating a legacy Symfony service to this shape without a freeze week. If you have ever opened a four-year-old Symfony app and felt the framework had eaten the domain, this is the book for getting it back.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)