Have you ever opened a class and immediately seen the whole problem?
Not because the code was bad, but because the class was secretly responsible for everything. It created clients, queried repositories, sent emails, logged events, read config, called APIs, and maybe made coffee if you asked nicely.
That kind of code usually has one missing idea: dependency injection.
In Symfony, dependency injection is not a side feature. It's one of the framework's deepest architectural ideas. It quietly changes how you design services, test code, replace infrastructure, and keep large applications understandable.
Dependency Injection Means Classes Ask For What They Need
Dependency injection is simple at the class level: instead of creating dependencies inside a class, you pass them in.
Think of a class like a chef. A bad kitchen makes the chef grow vegetables, forge knives, build the oven, and then cook dinner. A good kitchen gives the chef the tools and ingredients, so the chef can focus on the meal.
That's dependency injection.
Without Dependency Injection
Here's a service that creates its own dependency:
final class MonthlyReportSender
{
public function send(int $accountId): void
{
$mailer = new SmtpMailer($_ENV['SMTP_DSN']);
$report = new ReportBuilder()->build($accountId);
$mailer->send($report);
}
}
This is hard to test and hard to change. The class is glued to a specific mailer and report builder.
With Dependency Injection
Now the dependencies are passed through the constructor:
final class MonthlyReportSender
{
public function __construct(
private MailerInterface $mailer,
private ReportBuilder $reports,
) {}
public function send(int $accountId): void
{
$this->mailer->send($this->reports->build($accountId));
}
}
This class is easier to test, easier to read, and easier to replace. It does one job instead of building its whole universe.

The Symfony Container Builds The Object Graph
Symfony's service container is responsible for building objects and passing dependencies into them.
That sounds mechanical, but it's powerful. Your application may have hundreds or thousands of services. The container understands how they connect.
A service container is like a backstage crew in a theater. The audience sees the actor walk on stage, but backstage someone prepared the lights, props, microphone, costume, and timing. Good backstage work makes the performance look effortless.
Services Are Normal PHP Classes
In Symfony, a service is usually just a PHP class managed by the container.
final class PaymentRefunder
{
public function __construct(
private PaymentGateway $gateway,
private RefundRepository $refunds,
private LoggerInterface $logger,
) {}
public function refund(PaymentId $paymentId): void
{
$this->gateway->refund($paymentId);
$this->refunds->markRefunded($paymentId);
$this->logger->info('Payment refunded', ['paymentId' => (string) $paymentId]);
}
}
No container calls. No global helpers. No hidden construction. Just dependencies and behavior.
Autowiring Removes Most Boilerplate
Autowiring means Symfony reads type hints and automatically passes matching services into your constructor.
You don't need to manually define every dependency if Symfony can infer it safely. That's the nice part. The even nicer part is that Symfony tries to be predictable: when it can't decide which service to use, it tells you instead of guessing silently.
That's important. Magic that fails loudly is much safer than magic that smiles and chooses the wrong thing.
Autowiring Example
With default Symfony service configuration, this class can be wired automatically:
final class CancelOrderHandler
{
public function __construct(
private OrderRepository $orders,
private EventDispatcherInterface $events,
) {}
public function __invoke(CancelOrder $command): void
{
$order = $this->orders->get($command->orderId);
$order->cancel($command->reason);
$this->events->dispatch(new OrderCancelled($command->orderId));
}
}
The class only declares what it needs. Symfony handles the wiring.
Autoconfiguration Adds Behavior By Interface Or Attribute
Autoconfiguration lets Symfony automatically apply tags or behavior when a class implements an interface or uses a specific attribute.
This is extremely useful for handlers, event subscribers, commands, validators, and other framework-integrated classes.
Imagine a warehouse where every package with a red sticker automatically goes to priority shipping. You don't manually move each package. The sticker tells the system what to do.
Symfony tags work similarly.
Event Subscriber Example
final class AuditSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [OrderCancelled::class => 'onOrderCancelled'];
}
public function onOrderCancelled(OrderCancelled $event): void
{
// Write audit record here.
}
}
With autoconfiguration, Symfony can recognize this as an event subscriber and wire it into the dispatcher.
Compiler Passes Let You Customize The Container
A compiler pass lets you modify the service container while it is being compiled.
That sounds advanced because it is. You don't need compiler passes every day. But when you're building reusable bundles, plugin systems, handler registries, or custom framework behavior, compiler passes are incredibly useful.
Think of a compiler pass like inspecting and arranging all the wires before the machine is sealed. Once runtime starts, everything is already connected.
When Compiler Passes Make Sense
- Collect tagged services. Build a registry of handlers, strategies, exporters, or processors.
- Validate configuration early. Fail during container compilation instead of production runtime.
- Build framework extensions. Reusable bundles often need container-level setup.
- Optimize service wiring. Precompute maps instead of scanning classes at runtime.

Here's a small conceptual example:
final class PaymentProviderPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
$registry = $container->findDefinition(PaymentProviderRegistry::class);
foreach ($container->findTaggedServiceIds('app.payment_provider') as $id => $tags) {
$registry->addMethodCall('add', [new Reference($id)]);
}
}
}
The compiler pass gathers tagged payment providers and registers them in one place before the app runs.
Dependency Injection Makes Testing Less Painful
Testing is where dependency injection stops being theory.
If your class creates its own dependencies, your test has to fight reality. If your class accepts dependencies, your test can pass fakes, mocks, or in-memory implementations.
That's the difference between testing a car engine on a bench and testing it while driving on the highway blindfolded. One is controlled. The other is content for an incident report.
A Testable Service
public function test_it_marks_payment_as_refunded(): void
{
$gateway = new FakePaymentGateway();
$refunds = new InMemoryRefundRepository();
$service = new PaymentRefunder($gateway, $refunds, new NullLogger());
$service->refund(new PaymentId('pay_123'));
self::assertTrue($refunds->wasRefunded('pay_123'));
}
No real API call. No real database. No secret global state. That's a much calmer test.
Common Dependency Injection Problems
- Injecting the whole container. If a service asks for
ContainerInterface, it often hides its real dependencies. - Creating services manually.
new SomeService()inside business code defeats the container's purpose. - Too many constructor arguments. This usually means the class has too many responsibilities.
- Overusing compiler passes. They are powerful, but not every project needs custom container magic.
- Depending on concrete infrastructure. Interfaces make replacement and testing easier.
Dependency injection doesn't automatically create good architecture, but it makes bad architecture much harder to hide.
Pro Tips
- Constructor injection should be your default. It makes required dependencies impossible to miss.
- Use interfaces at boundaries. Payment gateways, mailers, external APIs, and repositories are good candidates.
- Keep services focused. A service with twelve dependencies is waving a little red flag at you.
- Let autowiring help, not confuse. Be explicit when multiple services implement the same interface.
- Use compiler passes for systems, not one-off tricks. They shine when you're building reusable infrastructure.
Final Tips
The first time dependency injection really clicks, you start seeing hidden construction everywhere. It's almost annoying. You look at old code and think, "Why is this class building its own database client? Who hurt you?"
But that awareness is useful. Symfony's container gives you a practical way to turn that awareness into cleaner, more testable design.
Start by making dependencies visible. The architecture gets calmer from there. Good luck untangling the container spaghetti 👊




