Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
PHPDevelopmentTutorial

What is dependency injection? A practical guide with PHP examples

What dependency injection is, the problem it solves, constructor, setter and method injection in PHP, a small working container, and how Laravel and Symfony do it for you.

Cloudsoft Zone

Updated 7 min read

A pile of colourful interlocking LEGO bricks, with the Cloudsoft Zone logo in the corner
In this article

Dependency injection (DI) means a class receives the objects it depends on from outside, usually through its constructor, instead of creating them itself. The class asks for "something that can send mail" rather than building an SMTP client, so the caller decides which implementation it gets. The result is code you can test without a database or mail server, and swap parts of without editing the class. A DI container is optional: it is a tool that builds those objects for you, but the pattern works with plain new.

Tested with PHP 8.5.7. The examples use constructor property promotion and readonly properties, so they need PHP 8.2 or later; the ideas apply to any object-oriented language.

The problem: a class that builds its own dependencies

PHP
<?php
final class InvoiceMailer
{
    public function send(int $invoiceId): void
    {
        $db = new PDO('mysql:host=127.0.0.1;dbname=shop', 'shop', 'secret'); // hidden dependency
        $smtp = new SmtpClient('smtp.example.com', 587);                        // another one
        // ... load the invoice, build the message, send it
    }
}

This class works, but it decides everything itself: which database, which credentials, which mail server. To test it you need a real MySQL server and a real SMTP server. To switch to a mail API you have to edit the class. And nothing in its signature tells a reader that it touches a database at all.

The fix: ask for dependencies in the constructor

PHP
<?php
declare(strict_types=1);

interface InvoiceRepository
{
    public function find(int $id): ?Invoice;
}

interface Mailer
{
    public function send(string $to, string $subject, string $body): void;
}

final readonly class Invoice
{
    public function __construct(public int $id, public string $email, public int $totalCents) {}
}

final class InvoiceMailer
{
    // Constructor injection: the class says what it needs and never builds it itself.
    public function __construct(
        private readonly InvoiceRepository $invoices,
        private readonly Mailer $mailer,
    ) {}

    public function send(int $invoiceId): void
    {
        $invoice = $this->invoices->find($invoiceId)
            ?? throw new DomainException("Invoice {$invoiceId} not found");
        $total = number_format($invoice->totalCents / 100, 2);
        $this->mailer->send($invoice->email, "Invoice #{$invoice->id}", "Amount due: {$total}");
    }
}

InvoiceMailer now depends on two small interfaces. It does not know or care whether invoices come from MySQL or an API, or whether mail goes through SMTP or a queue. Whoever creates it decides, in one place:

PHP
$mailer = new InvoiceMailer(
    new PdoInvoiceRepository($pdo),
    new SmtpMailer($smtpConfig),
);

That place, near the start of the application, is called the composition root. It is the only code that knows about concrete classes.

Why it matters: testing without a database

PHP
<?php
declare(strict_types=1);
require __DIR__ . '/di-after.php';

// In a test, hand the class simple fakes: no database, no SMTP server.
final class InMemoryInvoices implements InvoiceRepository
{
    public function __construct(private array $rows) {}
    public function find(int $id): ?Invoice { return $this->rows[$id] ?? null; }
}

final class RecordingMailer implements Mailer
{
    public array $sent = [];
    public function send(string $to, string $subject, string $body): void { $this->sent[] = [$to, $subject, $body]; }
}

$mailer = new RecordingMailer();
$service = new InvoiceMailer(new InMemoryInvoices([7 => new Invoice(7, 'ana@example.com', 129900)]), $mailer);
$service->send(7);
var_dump($mailer->sent === [['ana@example.com', 'Invoice #7', 'Amount due: 1,299.00']]); // bool(true)

The test builds the service with two tiny fakes and checks exactly what would have been sent. It runs in milliseconds, needs no network, and fails only if InvoiceMailer itself is wrong. This is the main practical reason to use DI.

The three ways to inject a dependency

PHP
<?php
declare(strict_types=1);

interface Logger { public function info(string $message): void; }
final class NullLogger implements Logger { public function info(string $message): void {} }

final class ReportExporter
{
    private Logger $logger;

    public function __construct() { $this->logger = new NullLogger(); } // a safe default

    // Setter injection: for optional collaborators the class works without.
    public function setLogger(Logger $logger): void { $this->logger = $logger; }

    // Method injection: the dependency is needed for this one call only.
    public function export(array $rows, Closure $write): int
    {
        foreach ($rows as $row) $write(implode(',', $row) . "\n");
        $this->logger->info(count($rows) . ' rows exported');
        return count($rows);
    }
}
$e = new ReportExporter();
echo $e->export([[1, 'a'], [2, 'b']], fn (string $line) => print $line), "\n";
TypeHowUse it for
Constructor injectionPassed to __constructRequired dependencies. The object is complete from the start and the property can be readonly. Use this by default.
Setter (property) injectionA setLogger()-style method after constructionOptional collaborators with a safe default, such as a logger. Avoid it for required ones: the object can exist half-configured.
Method injectionPassed to the method that needs itSomething needed for one call only, or that changes on every call, such as the current request or an output stream.

Some texts list "interface injection" as a fourth type, where the dependency provides an injector method the class must implement. It is rare in PHP and you can ignore it.

What a DI container does

In a large application, building every object by hand in the composition root becomes long. A container automates it: you tell it which class implements each interface, and it reads constructor type hints with reflection to build everything else. Here is a working minimal version:

PHP
<?php
declare(strict_types=1);

// A minimal autowiring container: enough to see what Laravel's and Symfony's containers do for you.
final class Container
{
    private array $bindings = [];
    private array $instances = [];

    public function bind(string $abstract, Closure|string $concrete): void
    {
        $this->bindings[$abstract] = $concrete;
    }

    public function get(string $id): object
    {
        if (isset($this->instances[$id])) return $this->instances[$id];
        $concrete = $this->bindings[$id] ?? $id;
        $object = $concrete instanceof Closure ? $concrete($this) : $this->build($concrete);
        return $this->instances[$id] = $object; // one shared instance per id
    }

    private function build(string $class): object
    {
        $reflection = new ReflectionClass($class);
        if (!$reflection->isInstantiable()) throw new LogicException("No binding for {$class}");
        $arguments = [];
        foreach ($reflection->getConstructor()?->getParameters() ?? [] as $parameter) {
            $type = $parameter->getType();
            if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) {
                throw new LogicException("Cannot resolve \${$parameter->getName()} of {$class}");
            }
            $arguments[] = $this->get($type->getName());
        }
        return $reflection->newInstanceArgs($arguments);
    }
}
PHP
$container = new Container();
$container->bind(InvoiceRepository::class, InMemoryInvoices::class);
$container->bind(Mailer::class, fn () => new EchoMailer());

$container->get(InvoiceMailer::class)->send(42);
// ana@example.com | Invoice #42 | Amount due: 50.00

(new Container())->get(InvoiceMailer::class);
// LogicException: No binding for InvoiceRepository

Notice that InvoiceMailer did not change at all to work with the container. That is the point: DI is a design choice in your classes, and the container is plumbing. Real containers add what this one leaves out, such as non-shared services, scalar configuration, compiled containers for speed, and clear errors for circular dependencies.

DI in Laravel, Symfony and CakePHP

  • Laravel: type-hint a class or interface in a controller, job or command constructor and the service container resolves it. Bind interfaces in a service provider with $this->app->bind() or singleton().
  • Symfony: services are autowired by type and the container is compiled, so wiring errors appear when the cache is built rather than at runtime.
  • CakePHP 5: has a container for controllers and commands; see dependency injection in CakePHP 5.
  • PSR-11 standardises the get() and has() methods of containers, so libraries can work with any of them.

The same idea drives NestJS in Node.js; if you meet its "can't resolve dependencies" error, our NestJS dependency error guide explains it in the same terms.

Common mistakes

  • Passing the container around (service locator). A class that receives the container and calls $container->get() inside hides its dependencies again. Inject the specific objects instead.
  • Constructors with ten parameters. DI makes this visible, which is useful: the class probably does too much and should be split.
  • Interfaces for everything. You do not need an interface for a value object or a class with only one sensible implementation. Add one when you need a second implementation or a fake in tests.
  • Injecting values that change per request into shared services. A service the container shares should not hold the current user; pass that to the method instead.

Dependency inversion is the "D" in SOLID: high-level code should depend on abstractions, such as the Mailer interface, not on details. Dependency injection is a common way to achieve it. Inversion of control is the broader idea that a framework or container, not your class, controls how objects are created and called. For more on these principles in interview form, see our PHP OOP interview questions.

Frequently asked questions

  • What is dependency injection in simple terms?

    A class gets the objects it needs from outside, usually as constructor arguments, instead of creating them with new inside. The caller chooses the implementation, which makes the class easier to test and change.

  • Do I need a container to use dependency injection?

    No. Passing objects to constructors by hand is dependency injection. A container only automates building the objects, which helps in large applications.

  • What is the difference between dependency injection and dependency inversion?

    Dependency inversion is a principle: depend on abstractions, not concrete classes. Dependency injection is a technique for supplying those dependencies from outside, and the usual way to follow the principle.

  • Is a service locator the same as dependency injection?

    No. With a service locator the class fetches its dependencies from a registry itself, so they are hidden from its signature. With dependency injection they are passed in and visible.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Inversion of Control Containers and the Dependency Injection pattern — Martin Fowler, 2004-01-23
  2. PSR-11: Container interface — PHP-FIG
  3. Service Container — Laravel
  4. Service Container — Symfony
  5. The ReflectionClass class — PHP Manual
  6. Constructor promotion — PHP Manual

Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.