- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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
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;
}
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();
}
}
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.
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
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\MessageHandlerInterfacegets 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
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;
}
}
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
}
#[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.
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'
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,
) {}
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;
}
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;
}
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);
}
}
}
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
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
_defaultsblock (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.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)