- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + 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
You add a constructor argument to a service. A new interface, one you just wrote. You run the app and it works. No config touched, no factory edited, nothing registered. The container saw the type hint, found the one class that implements it, and wired it for you.
That is autowiring doing exactly what it promises. It read the constructor, matched the type to a concrete class, and built the object graph without you writing a line of glue. On a Symfony or Laravel service with one implementation per interface, it is the right call.
Now change the shape. You write a second implementation of that interface. You run the app and it throws:
Cannot autowire service "App\Order\PlaceOrder":
argument "$gateway" of method "__construct()"
references interface "App\Port\PaymentGateway"
but no such service exists. You should maybe
alias this interface to one of these existing
services: App\Payment\StripeGateway,
App\Payment\AdyenGateway.
The container went from confident to confused the moment the answer stopped being unique. That is the trade at the center of this post. Autowiring resolves what is unambiguous. Everything ambiguous, everything that carries a decision, is exactly what you want to see written down.
What autowiring actually does
Autowiring is reflection plus a lookup table. When the container needs to build a class, it reads the constructor parameters, and for each type-hinted argument it asks: is there a service registered for this type? If yes, it recurses and builds that one too. If the type maps to exactly one candidate, the match is silent and correct.
In Symfony, the default services.yaml turns it on for your own namespace:
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude:
- '../src/Domain/'
- '../src/Kernel.php'
Every class under App\ becomes a service, and constructor args resolve by type. Laravel does the same thing through its container: app()->make(SomeClass::class) reflects the constructor and resolves each dependency, recursively, with zero registration for the common case.
For a controller that needs a Request, a logger, and one repository with one implementation, this is the correct amount of ceremony: none. The type hints are unambiguous, the graph is shallow, and writing a factory by hand would add noise without adding information.
Where the magic starts costing you
The bill arrives when the type hint stops being a unique answer. Three cases show up again and again.
Two implementations of one interface. The PaymentGateway example above. The container cannot pick. You now owe it a decision, and the only question is where you write that decision down.
A scalar or a value the container cannot infer. An adapter needs a base URL, a timeout, an API key. There is no type to match string $endpoint against. Autowiring has nothing to grab.
final readonly class HttpPaymentGateway
implements PaymentGateway
{
public function __construct(
private ClientInterface $http,
private string $endpoint,
private int $timeoutSeconds,
) {}
}
The $http resolves by type. The $endpoint and $timeoutSeconds do not, and the container fails or, worse, silently injects an empty string if you were careless with defaults.
A decorator or a wrapped instance. You want PlaceOrder wrapped in a transactional decorator. The container, left alone, has no idea you meant to wrap it. It will build a bare PlaceOrder and hand it out, and the transaction boundary you thought you had never existed.
None of these are edge cases. They are the normal shape of any service that talks to the outside world, and they are precisely the wiring decisions that carry business meaning. That is the pattern: the more a binding matters, the less likely autowiring can guess it, and the more you want it visible.
The graph you cannot see
Here is the quieter cost. Open a mature Symfony app and run:
php bin/console debug:container --show-arguments
You get hundreds of services, most of them wired by reflection. The dependency graph is real, it is load-bearing, and it lives nowhere you can read top to bottom. To answer "what does PlaceOrder actually depend on at runtime," you read the constructor, then chase each interface to its alias, then chase each alias to its implementation, then repeat for that implementation's constructor.
The graph exists. You just cannot see it in one place. When a new engineer asks "what happens when an order is placed," the honest answer is a spelunking expedition through type hints and container aliases.
Compare that to a file that states the graph as data:
$c->set(Clock::class, fn() => new SystemClock());
$c->set(PaymentGateway::class, fn(C $c) =>
new HttpPaymentGateway(
$c->get(ClientInterface::class),
$_ENV['PAYMENT_GATEWAY_URL'],
timeoutSeconds: 5,
),
);
$c->set(OrderRepository::class, fn(C $c) =>
new DoctrineOrderRepository(
$c->get(EntityManagerInterface::class),
$c->get(OrderRecordMapper::class),
),
);
$c->set(PlaceOrder::class, fn(C $c) =>
new TransactionalDecorator(
new PlaceOrder(
$c->get(CustomerRepository::class),
$c->get(OrderRepository::class),
$c->get(PaymentGateway::class),
$c->get(EventBus::class),
$c->get(Clock::class),
),
$c->get(EntityManagerInterface::class),
),
);
You read that once and you know the whole story. Which adapter backs which port. Where the timeout comes from. That PlaceOrder is wrapped, and by what. No reflection to trace, no alias to chase. The decorator is right there in the code, not implied by a config convention.
The line to draw
The split that holds up in practice: autowire the boring, wire the domain by hand.
Let the container autowire controllers, event subscribers, console commands, form types, framework glue. These have shallow graphs, unique dependencies, and no business decision hiding in their construction. Reflection resolves them correctly and writing factories for them is busywork.
Wire the domain use cases and their ports explicitly, in one composition root you own. PlaceOrder, CancelOrder, the repositories, the gateways, the decorators. These are the objects where a wiring choice is a design choice: which storage backend, which payment provider, whether the call runs in a transaction. Writing those bindings by hand is not overhead. The writing is the point. The file becomes the map of your application's real structure.
In Symfony you do not have to abandon the container to get this. You keep autowiring on for the framework layer and switch to explicit definitions for the domain layer:
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude:
- '../src/Domain/'
- '../src/Application/'
# domain wiring, stated by hand
App\Application\Order\PlaceOrder:
arguments:
$orders: '@App\Infrastructure\Persistence\Doctrine\DoctrineOrderRepository'
$payments: '@App\Infrastructure\Payment\HttpPaymentGateway'
App\Infrastructure\Payment\HttpPaymentGateway:
arguments:
$endpoint: '%env(PAYMENT_GATEWAY_URL)%'
$timeoutSeconds: 5
The framework classes stay autowired. The use case and its adapters are named explicitly, so the interesting part of the graph reads as data in one place. You get the keystroke savings where they cost nothing and the visibility where it earns its keep.
When one implementation becomes two
The real test of a wiring style is what a change costs. Say you add a second payment provider behind a feature flag.
With everything autowired, you first hit the "cannot autowire, no such service" error, then you reach for a container alias or a #[Target] attribute or a compiler pass, and the decision about which gateway is live ends up spread across an alias declaration, an env var, and maybe a service tag. To find out which gateway production uses, you assemble the answer from three files.
With an explicit composition root, the change is local and readable:
$c->set(PaymentGateway::class, fn(C $c) =>
$_ENV['PAYMENT_PROVIDER'] === 'adyen'
? new AdyenGateway(/* ... */)
: new StripeGateway(/* ... */),
);
The decision sits in the one file whose job is decisions. A reviewer sees the branch, sees both providers, sees the env var that chooses. Nothing is implied by convention. The magic did not disappear from the framework layer; you just kept it away from the layer where guessing is expensive.
The rule of thumb
Ask one question of every binding: if this were wrong, would the failure be loud or quiet?
A missing controller dependency fails loudly at boot. Let autowiring handle it. A wrong payment gateway, a missing transaction wrapper, a timeout that silently defaulted to zero — those fail quietly, in production, on a Friday. Write those down where a human reads them before they ship.
Autowiring is a fine default for the parts of the system where the answer is unique and the cost of a mistake is a stack trace at startup. The composition root is for the parts where the answer is a choice and the cost of a mistake is a debugging session no one enjoys.
Keeping the domain's wiring explicit, in a composition root the application owns rather than the framework infers, is one of the seams that lets the code outlive the framework underneath it. When the container is one adapter among several instead of the thing that secretly assembles your business logic, swapping it out later is an afternoon, not a rewrite. The whole argument of Decoupled PHP is that boundary: framework as a plug-in, domain wired by hand.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)