“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 make a design better. It merely exchanges one form of coupling for another.
The decision is about how behavior is allowed to vary.
Inheritance fixes variation into a type hierarchy. Composition puts variation behind collaborators that can change independently. When tax, discounts, shipping, and payment rules evolve along different axes, forcing them into one family tree is expensive. When a subtype genuinely preserves a small, stable parent contract, inheritance may be the clearest tool available.
That is the distinction worth carrying into a Laravel codebase. “Has-a” versus “is-a” is too shallow to decide it.
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 inherently wrong with this code. It has one controlled variation and a parent that 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.
The problem is not that inheritance is rigid in the abstract. The problem is that one hierarchy cannot represent several independent dimensions without making those dimensions aware of one another.
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 useful property is not “more classes.” It is that adding pickup does not require a tax subclass, and changing a contract discount does not require a shipping subclass. The dependencies 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 is wiring, not the design. If the interfaces do not represent real independent changes, moving construction into a service provider only hides an unnecessary abstraction.
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 has costs that its slogan tends to omit.
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 are not arguments against composition. They tell us where to test it. 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 constructing a controller, database, payment gateway, and queue worker around it.
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:
- Callers can use every child through the parent contract without knowing the concrete type.
- The parent owns a stable invariant, not a growing collection of optional hooks and boolean switches.
- 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; accepting that broad
parent is the entry price for participating in the ORM lifecycle. It does not
show that the parent is small or stable, and it does not justify copying the
same hierarchy into 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 does not begin by replacing every extends keyword. It begins
with one variation that is making the hierarchy difficult to change.
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 an 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, but convenience is not the reason to choose it. 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.