Lesson 16 • Advanced
Dependency Injection 💉
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.
What You'll Learn in This Lesson
- Spot the problem with calling new inside a class (tight coupling)
- Use constructor injection to pass dependencies in from outside
- Program to an interface instead of a concrete class
- Explain inversion of control in one sentence
- Build a tiny DI container with bind, singleton and resolve
- Choose singleton vs new-instance lifetimes and recognise PSR-11
php file.php locally. The Output panel under each example shows exactly what to expect.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";MySQL ran: SELECT * FROM users
Locked to MySQL — untestable, unswappable.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 usersMySQL ran: SELECT * FROM users
Postgres ran: SELECT * FROM users
FAKE ran: SELECT * FROM usersSame 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";[file] report built
Same logger instance? yes
Same report instance? noThe 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("sam@example.com") . "\n";
// ✅ Expected output:
// Email sent to sam@example.com
?>Email sent to sam@example.comMailer interface in the constructor, call send(), and inject a real SmtpMailer. The output should be one line.One more — this time register a service as a singleton and prove the container hands back the same object twice.
<?php
// 🎯 YOUR TURN — register a service and reuse it as a singleton.
// Fill in each blank marked ___ , then run it.
class Container {
private array $bindings = [];
private array $shared = [];
public function singleton(string $id, callable $factory): void {
$this->bindings[$id] = $factory;
}
public function get(string $id): mixed {
if (isset($this->shared[$id])) return $this->shared[$id];
$this->shared[$id] = ($this->bindings[$id])();
return $this->shared[$id];
}
}
class Clock {
public function now(): string { return "tick"; }
}
$c = new Container();
// 1) Register Clock as a singleton. The factory just builds a new Clock.
$c->singleton(Clock::class, fn() => new ___()); // 👉 the class to build
// 2) Resolve it twice out of the container.
$first = $c->get(Clock::class);
$second = $c->___(Clock::class); // 👉 the resolve method
// A singleton must hand back the SAME object both times.
echo ($first === $second ? "same instance" : "different") . "\n";
// ✅ Expected output:
// same instance
?>same instanceClock in the factory and resolve it with get(). Because it's a singleton, both lookups return the same instance.Common Errors (and the fix)
- Calling
newon a dependency inside a class — the original sin of tight coupling. The moment you write$this->db = new MySQLConnection();the class is welded to MySQL and can't be tested. Accept the dependency as a constructor argument instead and let the caller decide. - The service locator anti-pattern — passing the whole
Containerinto a class and calling$container->get(...)inside it. This just hides the dependencies again — the constructor no longer tells you what the class needs. Inject the specific services it requires, not the container. - Over-injection (too many dependencies) — a constructor with 7+ injected services is a smell that the class is doing too much. Split it into smaller, focused classes with 2–3 dependencies each; the pain of a huge constructor is the design telling you something.
- Type-hinting a concrete class instead of an interface — writing
__construct(private MySQLConnection $db)brings tight coupling back in disguise. Depend on an interface (private Database $db) so any implementation, including a test fake, can be injected. - "Uncaught Error: Too few arguments to function __construct()" — you tried
new UserService()after adding an injected parameter. With DI the caller must supply the dependency:new UserService(new MySQLConnection()), or let a container build it.
Pro Tips
- 💡 Prefer constructor injection over setter injection. A required dependency in the constructor means the object can never exist in a half-built, invalid state.
- 💡 Use PHP 8 constructor promotion to keep DI tidy:
public function __construct(private readonly Database $db) {}declares and stores the property in one line. - 💡 Let the framework's container autowire. In Laravel or Symfony you usually just type-hint a dependency and the container builds and injects it for you — you rarely write factories by hand.
📋 Quick Reference — Dependency Injection
| Term | Example | What It Means |
|---|---|---|
| Constructor injection | __construct(private Database $db) | Pass dependencies in (preferred) |
| Program to interface | private Database $db | Type-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-11 | ContainerInterface | Standard get()/has() container API |
Frequently Asked Questions
Q: 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.
Q: 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.
Q: 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.
Q: 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.
Q: 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.
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
?>Channel interface, two implementations, and a Notifier that injects the interface — then swap channels without touching Notifier.🎉 Lesson Complete!
- ✅ Building dependencies with
newinside a class causes tight coupling — untestable and unswappable - ✅ Constructor injection passes dependencies in from outside, so the caller stays in control
- ✅ Program to an interface, never a concrete class, so any implementation (including a test fake) fits
- ✅ Inversion of Control means the class no longer decides how its dependencies are created
- ✅ A DI container registers services with
bind()/singleton()and resolves them withget() - ✅ Lifetimes: singleton = one shared instance, bind = a fresh one each time; PSR-11 standardises the container API
- ✅ Next lesson: Advanced Error Handling — custom exception hierarchies and clean failure paths
Sign up for free to track which lessons you've completed and get learning reminders.