Index

Object Design

Composition Over Inheritance Is a Decision, Not a Rule

“Favor composition over inheritance” is useful advice right up to the point where it becomes a reflex. Replacing every parent class with an interface and a service-container binding does not improve the design. It just exchanges one form of coupling for another, usually with more files.

What matters is how behavior is allowed to vary. Inheritance fixes variation into a type hierarchy. Composition puts it behind collaborators that can change independently. When tax, discounts, shipping, and payment rules evolve along different axes, forcing them into one family tree gets expensive. When a subtype genuinely preserves a small, stable parent contract, inheritance may be the clearest tool available. “Has-a” versus “is-a” is too shallow to decide between them.

A subclass inherits more than code

PHP gives a child class the parent’s public and protected members and allows the child to override inherited methods. That is the language mechanism, as described in the PHP inheritance documentation . The design consequence is larger: a child also inherits the parent’s assumptions.

Consider a checkout hierarchy built around regional tax rules:

abstract class Checkout
{
    final public function total(Cart $cart): Money
    {
        return $cart->subtotal()->add($this->taxFor($cart));
    }

    abstract protected function taxFor(Cart $cart): Money;
}

final class FinnishCheckout extends Checkout
{
    protected function taxFor(Cart $cart): Money
    {
        return $cart->subtotal()->percentage(25.5);
    }
}

final class SwedishCheckout extends Checkout
{
    protected function taxFor(Cart $cart): Money
    {
        return $cart->subtotal()->percentage(25);
    }
}

There is nothing wrong with this code for the problem it has today. It has one controlled variation, and the parent owns the invariant order of operations.

Now add customer-specific discounts. Then add shipping methods. A checkout may need Finnish tax, a negotiated discount, and express shipping. Another may use the same tax and discount with pickup. The hierarchy has to choose a primary axis, so the other dimensions become flags, hooks, or combinations such as FinnishContractExpressCheckout.

One hierarchy cannot represent several independent dimensions for long without making those dimensions aware of one another. That is the actual problem; calling inheritance “rigid” does not explain much.

Independent change deserves independent objects

Composition makes each policy a separate decision:

interface TaxPolicy
{
    public function for(Cart $cart): Money;
}

interface DiscountPolicy
{
    public function for(Cart $cart, Customer $customer): Money;
}

interface ShippingPolicy
{
    public function for(Cart $cart, Address $destination): Money;
}

The checkout coordinates them without knowing which variants it received:

final readonly class Checkout
{
    public function __construct(
        private TaxPolicy $tax,
        private DiscountPolicy $discount,
        private ShippingPolicy $shipping,
    ) {}

    public function total(
        Cart $cart,
        Customer $customer,
        Address $destination,
    ): Money {
        return $cart->subtotal()
            ->add($this->tax->for($cart))
            ->add($this->shipping->for($cart, $destination))
            ->subtract($this->discount->for($cart, $customer));
    }
}

The extra classes are not the win. The win is that adding pickup does not require a tax subclass, and changing a contract discount does not require a shipping subclass. The dependencies now match the reasons the system changes.

Laravel’s container supports this design directly. It can bind an interface to an implementation , while contextual bindings can select a different implementation for a particular consumer. A normal binding remains deliberately boring:

use App\Contracts\DiscountPolicy;
use App\Contracts\ShippingPolicy;
use App\Contracts\TaxPolicy;
use App\Pricing\ContractDiscount;
use App\Pricing\FinnishTax;
use App\Shipping\ParcelShipping;

$this->app->bind(TaxPolicy::class, FinnishTax::class);
$this->app->bind(DiscountPolicy::class, ContractDiscount::class);
$this->app->bind(ShippingPolicy::class, ParcelShipping::class);

The container only wires the design together. If these interfaces do not represent real independent changes, the service provider is hiding unnecessary abstraction behind framework magic.

The policies can own different operational seams too. A time-limited discount can use a PSR-20 clock , which makes the time input predictable in tests. A carrier-backed shipping policy has to account for timeouts and failed responses; Laravel’s HTTP client documentation makes those separate conditions visible. Keep the clock and HTTP client inside the policies that own them. Checkout still has to decide whether a failed quote aborts the operation, falls back to another carrier, or makes shipping unavailable. Composition exposes that decision. It does not make the failure disappear.

Composition moves failure to assembly and policy boundaries

Composition is not free, and the slogan usually skips the bill.

The object graph must be assembled correctly. A missing or incorrect binding may not appear until Laravel resolves the service. Following a request through several small collaborators can also be harder than reading one concrete method. An interface introduced before a second real implementation exists may encode a guess that the next requirement disproves.

Those costs tell us where to test. Keep policy tests small, then add one integration test that resolves the checkout and its global policy bindings from the container:

it('assembles checkout with the production pricing policies', function () {
    $checkout = app(Checkout::class);

    expect($checkout)->toBeInstanceOf(Checkout::class);
    expect(app(TaxPolicy::class))->toBeInstanceOf(FinnishTax::class);
    expect(app(DiscountPolicy::class))->toBeInstanceOf(ContractDiscount::class);
    expect(app(ShippingPolicy::class))->toBeInstanceOf(ParcelShipping::class);
});

That test assumes the global bindings shown above. If Checkout has a contextual binding, resolving each interface independently does not prove what the checkout received. Assert one representative result through the resolved checkout as well.

That test is intentionally unimpressive. Its job is to catch wiring failures, not repeat every pricing rule through the framework. Each policy can be tested against its own inputs without dragging a controller, database, payment gateway, and queue worker into the room.

This is also where composition can prevent a mock fortress. A test for the checkout does not need a choreography of framework mocks. Small in-memory policies make the scenario explicit:

$checkout = new Checkout(
    tax: new FixedTax(Money::fromCents(255)),
    discount: new NoDiscount(),
    shipping: new FreeShipping(),
);

expect($checkout->total($cart, $customer, $address))
    ->toEqual(Money::fromCents(1255));

If replacing inheritance creates more complicated tests, inspect the new boundaries. The refactor may have decomposed code without clarifying behavior.

Inheritance still has to earn its extension points

Inheritance is appropriate when the subtype relationship is the design rather than a shortcut for reuse. Three conditions make that case stronger:

  1. Callers can use every child through the parent contract without knowing the concrete type.
  2. The parent owns a stable invariant, not a growing collection of optional hooks and boolean switches.
  3. The expected variations fit one hierarchy rather than several independent dimensions.

Framework inheritance is a separate constraint. Laravel documents Eloquent models as subclasses of Model; that broad parent is the entry price for participating in the ORM lifecycle. Pay it where the ORM requires it. Do not use it as evidence that the same hierarchy belongs in domain code.

A small importer can safely use a template method when the sequence is fixed and only row handling varies:

abstract class CsvImporter
{
    final public function import(iterable $rows): int
    {
        $imported = 0;

        foreach ($rows as $row) {
            $imported += $this->importRow($row) ? 1 : 0;
        }

        return $imported;
    }

    abstract protected function importRow(array $row): bool;
}

This remains readable because the parent has one invariant sequence and one extension point. Add hooks for validation, normalization, persistence, notifications, and error recovery, and the subclasses will soon depend on the order and side effects of protected methods. Those behaviors are separate collaborators trying to get out.

Refactor the axis that is actually changing

A safe refactor begins with one variation that is making the hierarchy hard to change, not a search-and-replace campaign against every extends keyword.

Suppose several checkout subclasses override discount calculation. Extract a DiscountPolicy, implement the current behavior behind it, and let the parent delegate to that policy. Migrate callers while characterization tests protect the existing totals. Remove the old hook only after nothing overrides it.

Then stop and look again. Shipping may deserve the same treatment; the fixed checkout sequence may not. Parallel change keeps the refactor reviewable and lets the design follow evidence instead of somebody’s imagined final architecture.

The reversal test is simple: if the new collaborators always change together, are always constructed together, and have no meaningful independent tests, the composition boundary may be artificial. Collapse it. Good composition is not measured by interface count.

Choose by the shape of change

Use inheritance when one stable abstraction defines an invariant and its subtypes remain honest substitutes. Use composition when behaviors vary independently, must be selected at runtime, or bring different dependencies and failure modes.

Laravel makes composition convenient. That still does not make it the default. Choose it when the seams match how the software changes. Otherwise, a concrete class—or even a small, disciplined hierarchy—is easier to explain, test, and maintain. Fewer interfaces is a perfectly good outcome when the evidence points there.