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:

PHP src/Report/MonthlyReportSender.php
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:

PHP src/Report/MonthlyReportSender.php
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.

Side-by-side comparison: on the left, "Manual Construction" shows an OrderService class tangled in red wires, with mailer, logger, repository, payment gateway, and event dispatcher all instantiated inside the class — labeled "tight coupling, hard to test, hard to change". On the right, "Dependency Injection" shows the same OrderService receiving its dependencies through clean blue arrows from a constructor — labeled "loose coupling, easy to test, easy to swap implementations".
Manual construction vs. dependency injection — same dependencies, very different coupling, testability, and maintainability.

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.

PHP src/Billing/PaymentRefunder.php
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:

PHP src/Order/CancelOrderHandler.php
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

PHP src/EventSubscriber/AuditSubscriber.php
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

  1. Collect tagged services. Build a registry of handlers, strategies, exporters, or processors.
  2. Validate configuration early. Fail during container compilation instead of production runtime.
  3. Build framework extensions. Reusable bundles often need container-level setup.
  4. Optimize service wiring. Precompute maps instead of scanning classes at runtime.

Detailed flow diagram of Symfony compiler passes: service definitions on the left feed into the container compilation phase, where multiple compiler passes — RegisterHandlersPass, RegisterCommandsPass, RegisterListenersPass, and other extension passes — collect tagged services and build runtime registries (handler registry, command registry, listener map, service locator). The right side shows the finished container being handed to the Symfony runtime, captioned "the application now runs with a fully wired container".
Compiler passes wire services before runtime: definitions in, tagged services collected, registries built, container ready.

Here's a small conceptual example:

PHP src/DependencyInjection/Compiler/PaymentProviderPass.php
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

PHP tests/Billing/PaymentRefunderTest.php
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

  1. Injecting the whole container. If a service asks for ContainerInterface, it often hides its real dependencies.
  2. Creating services manually. new SomeService() inside business code defeats the container's purpose.
  3. Too many constructor arguments. This usually means the class has too many responsibilities.
  4. Overusing compiler passes. They are powerful, but not every project needs custom container magic.
  5. 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

  1. Constructor injection should be your default. It makes required dependencies impossible to miss.
  2. Use interfaces at boundaries. Payment gateways, mailers, external APIs, and repositories are good candidates.
  3. Keep services focused. A service with twelve dependencies is waving a little red flag at you.
  4. Let autowiring help, not confuse. Be explicit when multiple services implement the same interface.
  5. 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 👊