Skip to main content
    Courses/PHP/Dependency Injection

    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

    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.

    Tight coupling — the class builds its own database
    <?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";
    Output
    MySQL ran: SELECT * FROM users
    Locked to MySQL — untestable, unswappable.
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Constructor injection + an interface
    <?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
    Output
    MySQL ran: SELECT * FROM users
    Postgres ran: SELECT * FROM users
    FAKE ran: SELECT * FROM users
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    A minimal DI container with two lifetimes
    <?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";
    Output
    [file] report built
    Same logger instance? yes
    Same report instance? no
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    🎯 Your turn: constructor injection
    <?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
    ?>
    Output
    Email sent to sam@example.com
    Type-hint the Mailer 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.

    🎯 Your turn: a singleton binding
    <?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
    ?>
    Output
    same instance
    Build a Clock 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 new on 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 Container into 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

    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

    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.

    🎯 Mini-Challenge: inject a notification channel
    <?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
    ?>
    Define a Channel interface, two implementations, and a Notifier that injects the interface — then swap channels without touching Notifier.

    🎉 Lesson Complete!

    • ✅ Building dependencies with new inside 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 with get()
    • 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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service