Dependency Injection

Reviewed & published by Brayan K

By the end of this lesson you'll know why creating dependencies with new inside a class is a trap, and how constructor injection, interfaces, and a small DI container let you build decoupled, testable PHP — the way Laravel and Symfony do under the hood.

Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ The Problem: new Inside a Class

A dependency is simply another object your class needs to do its job — a database, a logger, a mailer. The trap beginners fall into is letting a class build its own dependencies with new. That's called tight coupling: the two classes are glued together and can't be separated. Watch what goes wrong below.

<?php
// The PROBLEM: a class that creates its own dependency with 'new'.

class MySQLConnection {
    public function query(string $sql): string {
        return "MySQL ran: $sql";
    }
}

class UserService {
    private MySQLConnection $db;

    public function __construct() {
        // ❌ The class reaches out and builds its OWN database.
        //    It is now glued (tightly coupled) to MySQL forever.
        $this->db = new MySQLConnection();
    }

    public function getUsers(): string {
        return $this->db->query("SELECT * FROM users");
    }
}

$service = new UserService();          // no choice about which DB you get
echo $service->getUsers() . "\n";      // MySQL ran: SELECT * FROM users

// Why this hurts:
//  - You can NEVER swap MySQL for PostgreSQL or SQLite.
//  - You can NEVER pass a fake DB in a test (it always hits MySQL).
//  - The dependency is hidden inside the class, not visible to callers.
echo "Locked to MySQL — untestable, unswappable.\n";

Because UserService calls new MySQLConnection() itself, you're stuck with MySQL forever and you can never test the service without hitting a real database. The dependency is also hidden — nothing in the constructor tells a caller what this class actually needs.

2️⃣ Constructor Injection & Programming to Interfaces

Constructor injection fixes this: instead of building the dependency, the class receives it as a constructor argument. And rather than type-hinting a concrete class like MySQLConnection, you type-hint an interface — a contract that says "anything passed here must have a query() method." This is called programming to an interface, and it's what makes the class swappable and testable.

<?php
// The FIX: program to an INTERFACE and INJECT the dependency.

// 1) An interface = a contract. "Anything I accept must have query()."
interface Database {
    public function query(string $sql): string;
}

// 2) Real implementations both satisfy the contract.
class MySQLConnection implements Database {
    public function query(string $sql): string { return "MySQL ran: $sql"; }
}
class PostgresConnection implements Database {
    public function query(string $sql): string { return "Postgres ran: $sql"; }
}

class UserService {
    // 3) The dependency is PASSED IN. PHP 8 constructor promotion stores it.
    //    Type-hint the INTERFACE, never a concrete class.
    public function __construct(private Database $db) {}

    public function getUsers(): string {
        return $this->db->query("SELECT * FROM users");
    }
}

// 4) The CALLER decides which database to inject.
$mysql = new UserService(new MySQLConnection());
echo $mysql->getUsers() . "\n";        // MySQL ran: SELECT * FROM users

$postgres = new UserService(new PostgresConnection());
echo $postgres->getUsers() . "\n";     // Postgres ran: SELECT * FROM users

// 5) Tests can inject a fake — no real database needed.
$fake = new class implements Database {
    public function query(string $sql): string { return "FAKE ran: $sql"; }
};
echo (new UserService($fake))->getUsers() . "\n";  // FAKE ran: SELECT * FROM users

Same UserService, three different databases — including a one-off fake for testing — and the class never changed. The constructor signature now documents exactly what the service depends on. This shift, where the class no longer controls how its dependencies are created, is called Inversion of Control (IoC): control over construction is inverted and handed to the caller.

3️⃣ A DI Container, Autowiring & Lifetimes

Wiring everything by hand gets tedious once you have dozens of services. A DI container is a registry that knows how to build each service so you don't repeat the wiring. You register a factory with bind() (a fresh object each time — a transient lifetime) or singleton() (built once and reused — a shared lifetime), then ask for a service with get(). Real containers add autowiring: they read constructor type-hints via reflection and resolve the whole dependency tree automatically, so you rarely write the factories yourself.

<?php
// A small DI CONTAINER: it builds objects so YOU don't have to wire them by hand.

interface Logger { public function log(string $m): string; }

class FileLogger implements Logger {
    public function log(string $m): string { return "[file] $m"; }
}

class Report {
    // Report depends on a Logger contract — it never says 'new FileLogger'.
    public function __construct(private Logger $logger) {}
    public function run(): string { return $this->logger->log("report built"); }
}

class Container {
    private array $bindings = [];   // name => ['factory' => fn, 'shared' => bool]
    private array $shared   = [];   // cached singletons

    // bind() = a NEW object every time you resolve (transient lifetime).
    public function bind(string $id, callable $factory): void {
        $this->bindings[$id] = ['factory' => $factory, 'shared' => false];
    }

    // singleton() = built ONCE, then the same object is reused (shared lifetime).
    public function singleton(string $id, callable $factory): void {
        $this->bindings[$id] = ['factory' => $factory, 'shared' => true];
    }

    public function get(string $id): mixed {
        if (isset($this->shared[$id])) return $this->shared[$id];   // reuse
        $binding  = $this->bindings[$id];
        $instance = ($binding['factory'])($this);                   // build it
        if ($binding['shared']) $this->shared[$id] = $instance;     // cache it
        return $instance;
    }
}

$c = new Container();

// Register HOW to build each service. This is Inversion of Control:
// the container, not Report, owns the wiring.
$c->singleton(Logger::class, fn() => new FileLogger());           // one shared logger
$c->bind(Report::class, fn(Container $c) => new Report($c->get(Logger::class)));

$report = $c->get(Report::class);
echo $report->run() . "\n";                                      // [file] report built

// A singleton hands back the SAME instance every time:
$a = $c->get(Logger::class);
$b = $c->get(Logger::class);
echo "Same logger instance? " . ($a === $b ? "yes" : "no") . "\n";

// A bind() returns a FRESH instance every time:
$r1 = $c->get(Report::class);
$r2 = $c->get(Report::class);
echo "Same report instance? " . ($r1 === $r2 ? "yes" : "no") . "\n";

The PHP world standardises how you read from a container with PSR-11: a shared ContainerInterface with just two methods, get($id) and has($id). Laravel, Symfony, and PHP-DI all implement it, so code that type-hints the interface works with any of them.

4️⃣ Your Turn: Inject a Dependency

Now you try. The script below builds its mailer the wrong way — convert it to constructor injection. Fill in each ___ using the 👉 hint, then run it and check it against the Output panel.

<?php
// 🎯 YOUR TURN — convert tight coupling into constructor injection.
// Fill in each blank marked ___ , then run it.

interface Mailer {
    public function send(string $to): string;
}

class SmtpMailer implements Mailer {
    public function send(string $to): string { return "Email sent to $to"; }
}

class SignupService {
    // 1) Inject the Mailer instead of building it inside the class.
    //    Type-hint the INTERFACE, not SmtpMailer.
    public function __construct(private ___ $mailer) {}   // 👉 the interface name

    public function register(string $email): string {
        // 2) Use the injected mailer to send to $email.
        return $this->mailer->___($email);                // 👉 the method name
    }
}

// 3) Inject a real SmtpMailer from OUTSIDE the class.
$service = new SignupService(new ___());                  // 👉 the concrete class
echo $service->register("[email protected]") . "\n";

// ✅ Expected output:
//    Email sent to [email protected]
?>

One more — this time register a service as a singleton and prove the container hands back the same object twice.

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Dependency Injection

TermExampleWhat It Means
Constructor injection__construct(private Database $db)Pass dependencies in (preferred)
Program to interfaceprivate Database $dbType-hint the contract, not a class
bind()$c->bind(Id::class, fn)New instance each resolve (transient)
singleton()$c->singleton(Id::class, fn)Built once, reused (shared)
get() / resolve()$c->get(Id::class)Fetch an instance from the container
PSR-11ContainerInterfaceStandard get()/has() container API

Mini-Challenge: A Pluggable Notifier

No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This is the same write-run-check loop you'll use on every real refactor.

<?php
// 🎯 MINI-CHALLENGE: a notifier wired with dependency injection.
// No code is filled in — work from the steps below, then run it.
//
// 1. Define an interface 'Channel' with a method send(string $msg): string.
// 2. Make two classes that implement it:
//      - EmailChannel  -> returns "EMAIL: $msg"
//      - SmsChannel    -> returns "SMS: $msg"
// 3. Make a class 'Notifier' whose constructor INJECTS a Channel
//      (type-hint the interface, use PHP 8 constructor promotion).
//      Give it notify(string $msg) that calls the channel's send().
// 4. Create a Notifier with an EmailChannel, then echo notify("Hi").
// 5. Create another Notifier with an SmsChannel, then echo notify("Hi").
//
// ✅ Expected output:
//    EMAIL: Hi
//    SMS: Hi

// your code here
?>

🎉 Lesson Complete!

Practice quiz

What is dependency injection in one sentence?

  • A class loads its dependencies from a global variable
  • PHP automatically injects every class
  • A class is handed the objects it needs from outside instead of creating them with 'new'
  • A way to compress objects before storing them

Answer: A class is handed the objects it needs from outside instead of creating them with 'new'. DI means you pass dependencies in (usually via the constructor) rather than letting the class build them itself.

What problem does calling 'new MySQLConnection()' inside a class cause?

  • Tight coupling — the class is glued to MySQL and can't be tested or swapped
  • A syntax error
  • Slower compilation
  • Duplicate database rows

Answer: Tight coupling — the class is glued to MySQL and can't be tested or swapped. Building a dependency internally welds the class to one implementation, so you can't swap it or pass a fake in tests.

What is the preferred way to inject a required dependency?

  • Setter injection after construction
  • A global $container variable
  • Reading it from $_SESSION
  • Constructor injection

Answer: Constructor injection. Constructor injection means a required dependency is supplied at construction, so the object can never exist in a half-built, invalid state.

You should type-hint a dependency as what?

  • Always the concrete class
  • An interface (the contract), not a concrete class
  • The string 'mixed'
  • An array

Answer: An interface (the contract), not a concrete class. Programming to an interface (e.g. private Database $db) lets any implementation, including a test fake, be injected.

What is Inversion of Control (IoC)?

  • The class no longer controls how its dependencies are created — that control is handed to the caller or container
  • The database controls the application
  • Loops run in reverse order
  • Errors are thrown instead of returned

Answer: The class no longer controls how its dependencies are created — that control is handed to the caller or container. IoC is the broader idea; dependency injection is the most common way to achieve it.

What is a DI container?

  • A Docker image for PHP
  • A type of array
  • A registry that knows how to build each service so you don't wire them by hand
  • A database table of objects

Answer: A registry that knows how to build each service so you don't wire them by hand. You register how each service is built once, and the container constructs the object graph when you ask for something.

In the lesson's container, what does bind() create on each resolve?

  • The same shared instance forever
  • A fresh new instance every time (transient lifetime)
  • Nothing — it only registers a name
  • A database row

Answer: A fresh new instance every time (transient lifetime). bind() returns a new object every resolve (transient); singleton() builds once and reuses the same object (shared).

When should a service be a singleton (shared lifetime)?

  • For every object without exception
  • Only for objects you mutate per request
  • Never — singletons are an anti-pattern
  • For stateless, expensive-to-build, or globally-shared services like a logger or DB connection

Answer: For stateless, expensive-to-build, or globally-shared services like a logger or DB connection. Share things safe to share (logger, connection, config); create fresh instances whenever shared state would cause bugs.

What does PSR-11 define?

  • A password hashing standard
  • A common container interface with get($id) and has($id)
  • The cron expression format
  • A logging format

Answer: A common container interface with get($id) and has($id). PSR-11's ContainerInterface standardises how you read services from a container, so code works with Laravel, Symfony, PHP-DI, etc.

Why is passing the whole Container into a class (the service locator) an anti-pattern?

  • It is slower than autowiring
  • Containers can't be passed as arguments
  • It hides the real dependencies — the constructor no longer says what the class needs
  • It causes SQL injection

Answer: It hides the real dependencies — the constructor no longer says what the class needs. Inject the specific services a class requires, not the container, so dependencies stay visible in the constructor signature.

Continue this course

Frequently asked questions

What is dependency injection in simple terms?

Dependency injection (DI) means a class is handed the objects it needs from the outside instead of creating them itself with 'new'. If a UserService needs a database, you pass the database into its constructor rather than letting UserService build one internally. That single change makes the class easy to test, easy to reuse, and free to work with any database that fits the agreed interface.

What is the difference between dependency injection and a DI container?

Dependency injection is just the pattern of passing dependencies in (usually through the constructor) — you can do it by hand with no library at all. A DI container is a tool that automates that wiring: you register how each service is built once, and the container constructs the whole object graph for you when you ask for something. DI is the principle; the container is a convenience that scales it across a large app.

What is inversion of control?

Inversion of Control (IoC) is the broader idea that a class should not control how its dependencies are created — that control is inverted and handed to the caller or a container. Dependency injection is the most common way to achieve IoC. Instead of your class saying 'I will build my own logger', something else decides which logger it gets, so your class only depends on the abstraction.

What is PSR-11?

PSR-11 is a PHP-FIG standard that defines a tiny common interface for DI containers: ContainerInterface with two methods, get(string $id) and has(string $id). Because Laravel's container, Symfony's container, PHP-DI and others all implement it, you can type-hint Psr\Container\ContainerInterface and your code works with any of them. It standardises how you read services out of a container, not how you register them.

When should I use a singleton versus a new instance each time?

Use a singleton (shared lifetime) for stateless, expensive-to-build, or globally-shared services — a database connection, a logger, or a configuration object. Use a fresh instance (transient lifetime) when each consumer needs its own state, such as a request-specific object or a builder you mutate. The rule of thumb: share things that are safe to share, and create new ones whenever shared state would cause bugs.