Composition Over Inheritance and Dependency Injection

Understand why composing objects out of smaller collaborators is usually a safer default than inheriting behavior from a base class; apply constructor and method injection to make dependencies explicit and swappable; understand the dependency inversion principle that makes this work; and build a minimal service container that wires everything together automatically.

Inheritance answers the question 'is this a more specific version of that?' — a PremiumUser is a User. Composition answers a different question: 'what does this object need in order to do its job?' — an OrderProcessor needs a PaymentGateway, a Logger, and an InventoryChecker. The two are often confused because both let one class reuse another's code, but they fail differently: a deep inheritance chain locks behavior together at compile time and tends to crack the moment reality doesn't fit the hierarchy cleanly (is a Penguin really a Bird if Bird::fly() is assumed everywhere?). Composition keeps objects as independent, swappable collaborators wired together through their constructors, which is also the foundation dependency injection and every modern PHP framework's service container is built on.

Why inheritance breaks down in practice

A base class fixes a contract for every subclass forever. The moment one subclass needs to behave meaningfully differently — not just override a method, but violate an assumption the base class made — the hierarchy strains. A classic case: class ReportGenerator { public function generate(): string { $data = $this->fetchData(); return $this->render($data); } } with CsvReportGenerator and PdfReportGenerator subclasses overriding render(). This looks clean until a CachedReportGenerator is needed — does it extend CsvReportGenerator or PdfReportGenerator? PHP has no multiple inheritance, so the hierarchy has already run out of room for a legitimate combination of behaviors.

Composition sidesteps this entirely: instead of a ReportGenerator hierarchy, a single ReportGenerator class holds a DataFetcher and a Renderer as constructor-supplied collaborators. Caching becomes a CachingDataFetcher that wraps another DataFetcher (the decorator pattern) rather than a new branch of a class tree. Every combination — CSV with caching, PDF without, CSV without — is just a different set of objects passed to the same constructor, no new subclasses required.

The guideline 'favor composition over inheritance' does not mean inheritance is forbidden. Inheritance is still the right tool for a genuine, stable is-a relationship with shared invariants — an abstract Shape with a area() contract implemented by Circle and Rectangle is a reasonable hierarchy because the relationship is unlikely to need multiple, independently-varying behaviors bolted onto it later. The problem is reaching for inheritance by default, for code reuse alone, when the real relationship is 'has a' or 'uses a' rather than 'is a'.

Constructor injection versus method injection

Dependency injection means an object receives its collaborators from the outside rather than constructing them itself with new. Constructor injection — passing dependencies as constructor arguments and storing them in readonly properties — is the default choice for anything the object needs for its entire lifetime: a UserService needs its UserRepository for every method call, so it belongs in the constructor. This makes the dependency visible in the type signature, impossible to forget, and impossible to construct the object without.

Method injection — passing a dependency as an argument to a single method rather than the constructor — fits collaborators needed only for one specific operation, especially ones that vary per call rather than per object. A NotificationService::send(Message $message, NotificationChannel $channel) might accept the channel per call rather than baking one channel into the constructor, because which channel to use is a decision made at send-time, not at object-construction-time.

The failure mode to avoid is neither of these: reaching into a global, a static call, or new-ing a concrete class directly inside a method body. class OrderService { public function place(Order $order) { $logger = new FileLogger('/var/log/app.log'); ... } } hardcodes exactly which logger, to exactly which file, with no way to substitute a different one in a test or in a different environment — the dependency is hidden inside the method body instead of declared where callers and readers can see it.

Dependency inversion: depend on abstractions, not concretions

Constructor injection is most powerful when what's injected is an interface rather than a concrete class. class OrderService { public function __construct(private PaymentGatewayInterface $gateway) {} } lets OrderService work identically whether the concrete implementation passed in is StripeGateway, PaypalGateway, or a FakePaymentGateway used only in tests — OrderService never knows or cares which one it got. This is the dependency inversion principle: high-level modules (business logic like OrderService) should not depend on low-level modules (a specific payment vendor's SDK); both should depend on an abstraction (PaymentGatewayInterface) that neither owns exclusively.

This inversion is what makes testing without a real database, a real payment gateway, or a real email server possible: a test constructs OrderService with a FakePaymentGateway that returns a canned success response instantly, with no network call, no test-account credentials, and no flakiness from a third-party service being slow or down.

Service containers: automating the wiring

As an application grows, constructing the full object graph by hand — new OrderService(new StripeGateway($apiKey), new DbOrderRepository($pdo), new FileLogger($path)) — becomes repetitive and centralizes knowledge of every constructor's parameter list in one fragile place. A service container (also called a DI container or IoC container) automates this: it holds a registry of how to build each type, and resolves an entire dependency graph on request, typically by reading constructor parameter types via reflection.

A container does not replace dependency injection — it is a tool that performs the injection for you at the composition root (the single place in an application, usually near the entry point, where concrete implementations are decided and wired together). Everywhere else in the codebase keeps depending on interfaces and receiving dependencies through constructors exactly as before; only the wiring itself is automated.

Constructor injection and a minimal container

bind() registers how to build an abstraction; make() resolves a class, using an explicit binding if one exists, or falling back to reflecting the constructor and auto-resolving each typed parameter recursively. The container is only ever called at the composition root (application bootstrap). Ordinary classes like OrderService never reference the container — they just declare constructor dependencies.

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

final class OrderService
{
    public function __construct(
        private readonly PaymentGatewayInterface $gateway,
        private readonly LoggerInterface $logger,
    ) {}

    public function place(Order $order): bool
    {
        $ok = $this->gateway->charge($order->totalCents);
        $this->logger->info($ok ? 'order placed' : 'payment failed');
        return $ok;
    }
}

// Minimal container: register a factory closure per interface,
// resolve concrete classes automatically via reflection.
final class Container
{
    private array $bindings = [];

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

    public function make(string $class): object
    {
        if (isset($this->bindings[$class])) {
            return ($this->bindings[$class])($this);
        }
        $ctor = (new ReflectionClass($class))->getConstructor();
        if ($ctor === null) {
            return new $class();
        }
        $args = array_map(
            fn (ReflectionParameter $p) => $this->make((string) $p->getType()),
    
…

Code Examples

The first version cannot be tested without touching the real filesystem, and cannot be reused with any other logging destination. The second version declares what it needs (a LoggerInterface) in its constructor, so a test can supply an in-memory fake and production code can supply a real file logger — the class itself never changes. Expected result: Hello!

OrderService depends only on PaymentGatewayInterface, never on StripeGateway directly (dependency inversion). Production wires it to the real Stripe implementation; tests wire the exact same class to FakePaymentGateway, which records that it was called without ever contacting a network. Expected result: bool(true) bool(true)

Interfaces are bound once, at the composition root, to concrete implementations via factory closures. Container::make(OrderService::class) then reflects OrderService's constructor, sees it wants PaymentGatewayInterface and LoggerInterface, and resolves each through its binding — the caller never manually constructs OrderService or its dependencies.

$c->bind(PaymentGatewayInterface::class, fn () => new StripeGateway('sk_test_123'));

// Without injection: hardcoded, untestable
class Greeter
{
    public function greet(): string
    {
        $logger = new FileLogger('/var/log/app.log'); // hidden dependency
        $logger->info('greeting sent');
        return 'Hello!';
    }
}

// With constructor injection: explicit, swappable
class GreeterInjected
{
    public function __construct(private LoggerInterface $logger) {}

    public function greet(): string
    {
        $this->logger->info('greeting sent');
        return 'Hello!';
    }
}

$greeter = new GreeterInjected(new FileLogger('/var/log/app.log'));
echo $greeter->greet(); // Hello!
interface PaymentGatewayInterface
{
    public function charge(int $amountCents): bool;
}

final class StripeGateway implements PaymentGatewayInterface
{
    public function __construct(private string $apiKey) {}
    public function charge(int $amountCents): bool
    {
        // real HTTP call to Stripe would go here
        return $amountCents > 0;
    }
}

final class FakePaymentGateway implements PaymentGatewayInterface
{
    public bool $wasCalled = false;
    public function charge(int $amountCents): bool
    {
        $this->wasCalled = true;
        return true; // always succeeds, no network involved
    }
}

final class OrderService
{
    public function __construct(private PaymentGatewayInterface $gateway) {}
    public function place(int $amountCents): bool
    {
        return $this->gateway->charge($amountCents);
    }
}

// Production composition root
$service = new OrderService(new StripeGateway('sk_live_...'));

// Test composition root
$fake = new FakePaymentGateway();
$testService = new OrderService($fake);
var_dump($testService->place(500)); // true
var_dump($fake->wasCalled);         // true
interface LoggerInterface { public function info(string $message): void; }
final class NullLogger implements LoggerInterface { public function info(string $message): void {} }

interface PaymentGatewayInterface { public function charge(int $cents): bool; }
final class StripeGateway implements PaymentGatewayInterface {
    public function __construct(private string $apiKey) {}
    public function charge(int $cents): bool { return $cents > 0; }
}

final class OrderService {
    public function __construct(
        private PaymentGatewayInterface $gateway,
        private LoggerInterface $logger,
    ) {}
    public function place(int $cents): bool {
        $ok = $this->gateway->charge($cents);
        $this->logger->info($ok ? 'charged' : 'declined');
        return $ok;
    }
}

final class Container {
    private array $bindings = [];
    public function bind(string $abstract, callable $factory): void { $this->bindings[$abstract] = $factory; }
    public function make(string $class): object {
        if (isset($this->bindings[$class])) return ($this->bindings[$class])($this);
        $ctor = (new ReflectionClass($class))->getConstructor();
        if ($ctor === null) return new $c
…

Composition + injection vs. deep inheritance

Inheritance locks behavior into a single fixed hierarchy; composition lets independent collaborators be swapped or wrapped (like a caching decorator) without touching the class tree.

Inheritance (brittle):          Composition + injection (flexible):

  ReportGenerator                    ReportGenerator
     |                                  |         |
  CsvReportGenerator              DataFetcher   Renderer
     |                             (interface)  (interface)
  CachedCsvReportGenerator?             |            |
     (no clean slot for this)   CachingFetcher   CsvRenderer
                                 wraps DbFetcher   PdfRenderer
                                 (decorator, no new subclass needed)

Important Rules

Default to composition ('has a' / 'uses a') for code reuse; reserve inheritance for a genuinely stable 'is a' relationship with shared invariants.

Inject via the constructor anything an object needs for its whole lifetime; inject via a method only what varies per call.

Depend on interfaces, not concrete classes, for any collaborator that might reasonably be swapped (a payment gateway, a logger, a data source) — this is dependency inversion.

Never construct a hard dependency with new inside a method body — declare it as a constructor parameter instead, so it is visible and replaceable.

A service container automates wiring at the composition root only; it should never be reached for or referenced from inside ordinary business-logic classes (avoid the 'service locator' anti-pattern).

Where this shows up

Every major PHP framework's core is a service container: Laravel's App::make()/automatic constructor resolution, Symfony's ServiceContainer (configured via YAML/attributes and compiled for performance), and PSR-11 (Psr\Container\ContainerInterface) standardizes the get()/has() contract so libraries can be container-agnostic. Test suites depend entirely on dependency inversion: PHPUnit tests routinely construct the class under test with fake/mock implementations of its interfaces instead of real databases or HTTP clients, which is only possible because the class was written to depend on interfaces via its constructor in the first place.

class OrderService { public function place(Order $order): bool { $gateway = new StripeGateway(getenv('STRIPE_KEY')); // hidden, hardcoded return $gateway->charge($order->totalCents); } } class OrderService { public function __construct(private PaymentGatewayInterface $gateway) {} public function place(Order $order): bool { return $this->gateway->charge($order->totalCents); } }

Hardcoding new StripeGateway(...) inside the method hides the dependency from callers and makes OrderService impossible to test without a real Stripe account. Injecting PaymentGatewayInterface through the constructor makes the dependency visible, swappable, and testable.

class Container { public static Container $instance; } class OrderService { public function place(Order $order): bool { $gateway = Container::$instance->make(PaymentGatewayInterface::class); // service locator return $gateway->charge($order->totalCents); } }

Reaching into a global container from inside a class (the 'service locator' anti-pattern) still hides the real dependency — you have to read the method body to know what it needs. Constructor injection puts every dependency in the type signature where it belongs.

[multiple-choice] Why is 'favor composition over inheritance' generally good advice rather than an absolute rule? A. Because inheritance is deprecated in modern PHP and no longer works B. Because a genuinely stable 'is a' relationship with shared invariants is still a legitimate use of inheritance — the problem is defaulting to inheritance purely for code reuse when the real relationship is 'has a' C. Because composition is always faster at runtime than inheritance D. Because PHP classes can only extend one other class, so composition is required by the language

B. Because a genuinely stable 'is a' relationship with shared invariants is still a legitimate use of inheritance — the problem is defaulting to inheritance purely for code reuse when the real relationship is 'has a' — Inheritance remains appropriate for true is-a relationships with stable shared behavior. The guidance targets the common mistake of reaching for inheritance as a default reuse mechanism, which tends to produce brittle hierarchies that can't cleanly express combinations of independently-varying behavior.

[multiple-choice] A class's constructor requires a PaymentGatewayInterface. What principle does depending on the interface, rather than a concrete class like StripeGateway, demonstrate? A. Encapsulation B. Dependency inversion — high-level code depends on an abstraction that neither it nor the concrete implementation exclusively owns C. Late static binding D. Method overloading

B. Dependency inversion — high-level code depends on an abstraction that neither it nor the concrete implementation exclusively owns — Dependency inversion means business logic depends on abstractions (interfaces) rather than concrete low-level implementations, which is what allows swapping StripeGateway for FakePaymentGateway in tests without changing the depending class at all.

[multiple-choice] In the minimal Container::make() implementation shown, how does it decide what to pass for each constructor parameter that has no explicit binding? A. It always passes null B. It throws an error immediately for any unbound parameter C. It reflects the constructor's parameter types and recursively calls make() on each class-typed parameter D. It reads a YAML configuration file

C. It reflects the constructor's parameter types and recursively calls make() on each class-typed parameter — The container uses ReflectionClass to inspect the constructor's parameters, and for each one recursively calls make() on that parameter's type — building the full dependency graph automatically from type declarations alone.

Define an interface ClockInterface with a method now(): DateTimeImmutable. Write a SystemClock implementation that returns the real current time, and a FixedClock implementation (constructor takes a DateTimeImmutable) that always returns that fixed value. Write a class SubscriptionService that takes a ClockInterface via constructor injection and a method isExpired(DateTimeImmutable $expiresAt): bool that compares it against $this->clock->now(). Show that SubscriptionService can be tested deterministically using FixedClock.

interface ClockInterface { public function now(): DateTimeImmutable; } final class SystemClock implements ClockInterface { public function now(): DateTimeImmutable { return new DateTimeImmutable(); } } final class FixedClock implements ClockInterface { public function __construct(private DateTimeImmutable $fixed) {} public function now(): DateTimeImmutable { return $this->fixed; } } final class SubscriptionService { public function __construct(private ClockInterface $clock) {} public function isExpired(DateTimeImmutable $expiresAt): bool { return $this->clock->now() > $expiresAt; } } $fixed = new FixedClock(new DateTimeImmutable('2026-01-01')); $service = new SubscriptionService($fixed); var_dump($service->isExpired(new DateTimeImmutable('2025-12-31'))); // true, deterministic

The whole point is that SubscriptionService never calls new DateTimeImmutable() or time() directly — every notion of 'now' comes from the injected clock, so tests control time exactly.