Dependency Injection in PHP Without a Framework

Bryan Heath Bryan Heath
· · 12 min read

Dependency injection is one of those patterns that sounds academic until you see it in practice. At its core, the idea is simple: instead of a class creating the things it needs, you hand them in from the outside. That single shift makes your code testable, flexible, and far easier to reason about. Most PHP developers encounter DI through Laravel's service container and never think twice about it. But if you've ever wondered what's actually happening behind the scenes when you type-hint an interface in a controller constructor and it just works, this post is for you.

We're going to build a dependency injection container from scratch. Not a production-grade one — just enough to understand auto-wiring, singleton vs transient bindings, and interface resolution. By the end, you'll appreciate both how simple the core ideas are and why frameworks handle this for you.

The Problem With Direct Instantiation

Consider a class that creates its own dependencies using new:

class OrderProcessor
{
    private PaymentGateway $gateway;
    private Mailer $mailer;
    private Logger $logger;

    public function __construct()
    {
        $this->gateway = new StripeGateway('sk_live_xxx');
        $this->mailer = new SmtpMailer('smtp.example.com', 587);
        $this->logger = new FileLogger('/var/log/orders.log');
    }

    public function process(Order $order): void
    {
        $this->gateway->charge($order->total());
        $this->mailer->send($order->customerEmail(), 'Order confirmed');
        $this->logger->info('Order processed: ' . $order->id());
    }
}

This works, but it has serious problems. You cannot test OrderProcessor without hitting a real Stripe API, a real SMTP server, and a real filesystem. You cannot swap StripeGateway for BraintreeGateway without editing the class itself. Every instance of OrderProcessor is hardwired to the same concrete implementations. The class knows too much about how its collaborators are constructed.

Constructor Injection: The Simplest Form

The fix is straightforward: don't create dependencies inside the class. Require them as constructor arguments.

class OrderProcessor
{
    public function __construct(
        private PaymentGateway $gateway,
        private Mailer $mailer,
        private Logger $logger,
    ) {}

    public function process(Order $order): void
    {
        $this->gateway->charge($order->total());
        $this->mailer->send($order->customerEmail(), 'Order confirmed');
        $this->logger->info('Order processed: ' . $order->id());
    }
}

Now the caller decides which implementations to provide:

// In production
$processor = new OrderProcessor(
    new StripeGateway('sk_live_xxx'),
    new SmtpMailer('smtp.example.com', 587),
    new FileLogger('/var/log/orders.log'),
);

// In tests
$processor = new OrderProcessor(
    new FakeGateway(),
    new InMemoryMailer(),
    new NullLogger(),
);

This is dependency injection without any container at all. The class declares what it needs through its constructor signature, and the outside world provides it. Testing becomes trivial because you can pass in fakes, mocks, or stubs for every collaborator. This is the foundation everything else builds on.

Building a Basic Container

Constructor injection works perfectly until your dependency graph gets deep. If class A needs B, and B needs C, and C needs D, you end up with a long chain of manual instantiation at the top of your application. That's where a container comes in. A container is just a registry that knows how to build things.

Here's a minimal one:

class Container
{
    /** @var array<string, Closure> */
    private array $bindings = [];

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

    public function get(string $abstract): mixed
    {
        if (! isset($this->bindings[$abstract])) {
            throw new RuntimeException("No binding found for [{$abstract}].");
        }

        return ($this->bindings[$abstract])($this);
    }
}

The bind() method stores a factory closure keyed by a string name. The get() method calls that closure to produce an instance. Notice that the factory receives the container itself — this lets bindings resolve their own dependencies from the container.

$container = new Container();

$container->bind(Logger::class, function () {
    return new FileLogger('/var/log/app.log');
});

$container->bind(Mailer::class, function () {
    return new SmtpMailer('smtp.example.com', 587);
});

$container->bind(OrderProcessor::class, function (Container $c) {
    return new OrderProcessor(
        new StripeGateway('sk_live_xxx'),
        $c->get(Mailer::class),
        $c->get(Logger::class),
    );
});

$processor = $container->get(OrderProcessor::class);

This is already useful. All your construction logic lives in one place, and each binding can pull in other bindings by calling $c->get(). But every call to get() creates a new instance. Sometimes that is exactly what you want. Other times, you need the same instance shared across your entire application.

Singleton vs Transient Bindings

A transient binding creates a fresh instance every time you resolve it. A singleton binding creates an instance once and returns that same instance on every subsequent call. Database connections and loggers are classic singletons — you want one connection pool, not a new one per request.

Let's extend our container:

class Container
{
    /** @var array<string, Closure> */
    private array $bindings = [];

    /** @var array<string, mixed> */
    private array $instances = [];

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

    public function singleton(string $abstract, Closure $factory): void
    {
        $this->bindings[$abstract] = $factory;
        $this->instances[$abstract] = null; // mark as singleton
    }

    public function get(string $abstract): mixed
    {
        // Return cached singleton if already resolved
        if (array_key_exists($abstract, $this->instances) && $this->instances[$abstract] !== null) {
            return $this->instances[$abstract];
        }

        if (! isset($this->bindings[$abstract])) {
            throw new RuntimeException("No binding found for [{$abstract}].");
        }

        $instance = ($this->bindings[$abstract])($this);

        // Cache it if this is a singleton binding
        if (array_key_exists($abstract, $this->instances)) {
            $this->instances[$abstract] = $instance;
        }

        return $instance;
    }
}

The trick is simple. When singleton() is called, we store the factory just like bind(), but we also register the key in the $instances array. On the first get() call, we resolve the factory, cache the result, and return the cached instance on every subsequent call.

$container->singleton(Logger::class, function () {
    return new FileLogger('/var/log/app.log');
});

$a = $container->get(Logger::class);
$b = $container->get(Logger::class);

var_dump($a === $b); // true — same instance

Interface Binding

So far, we've been binding concrete class names to factories. The real power of a container comes when you bind interfaces to implementations. This lets consumer code depend on abstractions, and the container decides which concrete class to provide.

interface PaymentGateway
{
    public function charge(int $amountCents): bool;
}

class StripeGateway implements PaymentGateway
{
    public function __construct(private string $apiKey) {}

    public function charge(int $amountCents): bool
    {
        // Stripe API call
        return true;
    }
}

class BraintreeGateway implements PaymentGateway
{
    public function __construct(private string $merchantId) {}

    public function charge(int $amountCents): bool
    {
        // Braintree API call
        return true;
    }
}

Now you bind the interface to whichever implementation you want:

// In production
$container->bind(PaymentGateway::class, function () {
    return new StripeGateway('sk_live_xxx');
});

// Switching providers? Change one line.
$container->bind(PaymentGateway::class, function () {
    return new BraintreeGateway('merchant_abc');
});

Any class that depends on PaymentGateway doesn't know or care whether it's Stripe or Braintree. The container handles the wiring, and you can swap implementations without touching any consumer code. In tests, you bind a FakeGateway that returns hardcoded responses.

Auto-Wiring With Reflection

Everything we've built so far requires you to manually register every class. That's tedious. The real magic in modern DI containers is auto-wiring: the container inspects a class's constructor, figures out what it needs, and resolves each dependency automatically — recursively.

PHP's ReflectionClass API makes this possible:

class Container
{
    /** @var array<string, Closure> */
    private array $bindings = [];

    /** @var array<string, mixed> */
    private array $instances = [];

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

    public function singleton(string $abstract, Closure $factory): void
    {
        $this->bindings[$abstract] = $factory;
        $this->instances[$abstract] = null;
    }

    public function get(string $abstract): mixed
    {
        if (array_key_exists($abstract, $this->instances) && $this->instances[$abstract] !== null) {
            return $this->instances[$abstract];
        }

        if (isset($this->bindings[$abstract])) {
            $instance = ($this->bindings[$abstract])($this);
        } else {
            $instance = $this->resolve($abstract);
        }

        if (array_key_exists($abstract, $this->instances)) {
            $this->instances[$abstract] = $instance;
        }

        return $instance;
    }

    private function resolve(string $class): object
    {
        $reflector = new ReflectionClass($class);

        if (! $reflector->isInstantiable()) {
            throw new RuntimeException("[{$class}] is not instantiable.");
        }

        $constructor = $reflector->getConstructor();

        if ($constructor === null) {
            return new $class();
        }

        $dependencies = array_map(function (ReflectionParameter $param) {
            $type = $param->getType();

            if (! $type instanceof ReflectionNamedType || $type->isBuiltin()) {
                if ($param->isDefaultValueAvailable()) {
                    return $param->getDefaultValue();
                }

                throw new RuntimeException(
                    "Cannot resolve parameter [{$param->getName()}]."
                );
            }

            return $this->get($type->getName());
        }, $constructor->getParameters());

        return $reflector->newInstanceArgs($dependencies);
    }
}

The resolve() method is where the magic happens. It uses reflection to read the constructor's parameter types, and for each one, it recursively calls get() to resolve that dependency. If a parameter is a builtin type like string or int, it falls back to the default value or throws an exception. If it is a class or interface, the container resolves it — which may trigger further auto-wiring down the dependency tree.

Now you only need explicit bindings for interfaces and classes with scalar constructor arguments. Concrete classes with type-hinted dependencies resolve themselves:

// Only bind what can't be auto-wired
$container->bind(PaymentGateway::class, fn () => new StripeGateway('sk_live_xxx'));
$container->singleton(Logger::class, fn () => new FileLogger('/var/log/app.log'));

// OrderProcessor auto-wires: its constructor asks for
// PaymentGateway, Mailer, and Logger — all resolvable
$processor = $container->get(OrderProcessor::class);

Why Frameworks Make This Effortless

Our container is about 70 lines of code and handles the fundamentals. Laravel's container does all of this and far more. Here's how the same binding looks in Laravel:

// In a service provider
$this->app->bind(PaymentGateway::class, StripeGateway::class);
$this->app->singleton(Logger::class, FileLogger::class);

That's it. Two lines. Laravel's container auto-wires StripeGateway and FileLogger as well, so you only need closures when constructor arguments are scalar values that the container cannot infer.

Beyond basic binding, Laravel's container gives you features that would take hundreds of lines to replicate:

  • Contextual binding — give different implementations to different consumers. For example, PhotoController gets S3Filesystem while ReportController gets LocalFilesystem, both type-hinting the same Filesystem interface.

  • Tagged services — tag multiple bindings and resolve them all at once, useful for report generators, validation rules, or notification channels.

  • Method injection — controller methods, job handles, and event listeners all get auto-wired parameters, not just constructors.

  • Container events — hook into resolution to decorate, log, or modify instances as they are created.

You don't need to reimplement any of this. But understanding the pattern underneath makes you better at using the framework. When a binding doesn't resolve the way you expect, or when you're debugging a circular dependency, you'll know exactly where to look.

Building a DI container from scratch is one of the most clarifying exercises you can do as a PHP developer. It strips away the framework abstraction and shows you that dependency injection is just passing arguments to constructors — and a container is just a map of names to factory functions with some reflection sprinkled on top. You absolutely do not need your own container in production. Laravel's is battle-tested, feature-rich, and deeply integrated with the rest of the framework. But the next time you type-hint an interface in a controller and it magically resolves, you'll know exactly what's happening behind the curtain — and that understanding will make you a sharper, more confident developer.

Share:

Related Posts