Index

Open–Closed Protects Stable Policy from Repeated Variation

The third shipping carrier usually exposes the design more clearly than the first.

With one carrier, a conditional is honest. With three, adding a carrier may require editing quote calculation, label purchase, tracking links, validation, service-provider bindings, and tests. The same variation keeps reopening code that was already working.

The Open–Closed Principle addresses that pattern: establish a stable policy that is closed to this recurring modification while new variants arrive through an explicit extension point. It does not say existing code must never change. It says repeated variation should stop causing shotgun surgery.

First make the variation visible

Consider a label service that switches on a carrier code:

final readonly class BuyShippingLabel
{
    public function handle(Shipment $shipment): Label
    {
        return match ($shipment->carrier) {
            'dhl' => $this->buyDhlLabel($shipment),
            'ups' => $this->buyUpsLabel($shipment),
            'postnord' => $this->buyPostNordLabel($shipment),
        };
    }
}

The match is not inherently bad. It becomes expensive when each branch has different credentials, request mapping, error translation, and tests—and the same carrier switch appears elsewhere.

The stable policy is “buy a label for this shipment and return our label model.” The varying detail is how one carrier performs that operation:

interface Carrier
{
    public function supports(CarrierCode $code): bool;
    public function buyLabel(Shipment $shipment): Label;
}
final readonly class Carriers
{
    /** @var list<Carrier> */
    private array $carriers;

    /** @param iterable<Carrier> $carriers */
    public function __construct(iterable $carriers)
    {
        $this->carriers = [...$carriers];
    }

    public function for(CarrierCode $code): Carrier
    {
        $matches = array_values(array_filter(
            $this->carriers,
            fn (Carrier $carrier): bool => $carrier->supports($code),
        ));

        return match (count($matches)) {
            0 => throw UnsupportedCarrier::for($code),
            1 => $matches[0],
            default => throw AmbiguousCarrier::for($code),
        };
    }
}
final readonly class BuyShippingLabel
{
    public function __construct(private Carriers $carriers) {}

    public function handle(Shipment $shipment): Label
    {
        return $this->carriers
            ->for($shipment->carrier)
            ->buyLabel($shipment);
    }
}

A new carrier adds an adapter and registers it. The use case and registry do not change as long as the contract remains accurate.

Closed always means closed against a named change

No module is closed against every future requirement. If carriers begin returning customs documents, the Label contract may need to change. If label purchase becomes asynchronous, the use case boundary may change. If one carrier supports several accounts per tenant, selection may need more context.

State the axis explicitly:

Closed against: adding another carrier with the existing label contract
Open through:   another Carrier implementation and registration
Not closed:     changing what label purchase means

Without that sentence, “open for extension” becomes a request for speculative interfaces everywhere.

Robert Martin’s discussion of OCP traces the principle to Bertrand Meyer’s distinction between modules available for extension and modules stable enough for use. In application code, stability is selective: protect the policy that should remain reliable while allowing the observed variation to grow.

Data is often the better extension point

Not every new case needs polymorphism. Tax bands expressed as data are easier to audit than one class per percentage:

final readonly class TaxTable
{
    /** @param array<string, int> $basisPointsByCountry */
    public function __construct(private array $basisPointsByCountry) {}

    public function rateFor(CountryCode $country): BasisPoints
    {
        return BasisPoints::of(
            $this->basisPointsByCountry[$country->value]
                ?? throw UnknownTaxJurisdiction::for($country),
        );
    }
}

Choose data when variants differ only in values and share the same algorithm. Choose behavior objects when variants have different protocols, dependencies, failure semantics, or calculations. A class hierarchy for configuration data makes simple changes harder to inspect.

A match expression is also reasonable when the set is genuinely closed and the compiler or static analyzer should force exhaustive handling. PHP enums often benefit from one explicit match. Extensibility is not automatically better than exhaustiveness.

Registration should not become a service locator

Laravel can inject tagged implementations through the container, but the domain-facing registry should receive ordinary objects. Laravel’s container documentation supports both tagging and injecting every binding with a tag:

$this->app->tag([
    DhlCarrier::class,
    UpsCarrier::class,
    PostNordCarrier::class,
], 'shipping.carriers');

$this->app->when(Carriers::class)
    ->needs('$carriers')
    ->giveTagged('shipping.carriers');

The container handles assembly. BuyShippingLabel should not call app(Carrier::class) with a dynamic key; that hides dependencies and moves selection into global configuration.

Registration is still modification. Adding a class name to one composition root is materially different from editing business branches across the application.

An extension point is also a trust boundary

If registration must happen independently of the application deployment, a discovered package may be justified. That changes more than the release mechanism. Laravel package discovery automatically registers service providers declared by an installed package; the consuming application can opt individual packages, or all packages, out of discovery.

An interface does not sandbox an implementation. A carrier running in the same PHP process can use whatever filesystem, network, environment, and database access that process has. The Carrier contract limits what the application calls; it does not limit what the adapter can do.

Composer plugins are different from application-level carrier adapters, but they make the trust issue explicit. Composer’s plugin documentation says plugins are loaded into Composer at runtime. Its allow-plugins setting exists to restrict which packages may execute code during a Composer run.

Keep explicit registration when extensions ship with the application. If third parties must install extensions independently, require an allowlist, pinned compatible versions, defined boot and runtime failure behavior, an ownership path for security updates, and a way to disable or roll back one extension. Code that is not trusted with the application’s privileges belongs behind a process boundary, not merely behind a PHP interface.

Extension contracts need behavioral tests

An interface proves method shape, not substitutability. Every carrier must translate external behavior into the application’s contract:

function carrierContract(Closure $carrier): void
{
    it('returns a label with our shipment identity', function () use ($carrier) {
        $shipment = aShipment(id: 'shipment-123');

        $label = $carrier()->buyLabel($shipment);

        expect($label->shipmentId)->toEqual($shipment->id);
    });

    it('translates rejected credentials', function () use ($carrier) {
        expect(fn () => $carrier(invalidCredentials())->buyLabel(aShipment()))
            ->toThrow(CarrierAuthenticationFailed::class);
    });
}

Run the contract against each adapter, then keep focused tests for carrier-specific mapping. An integration test should prove the container registers all configured carriers and rejects duplicate support claims.

The extension seam also needs observability. Log the application carrier code, external request ID, normalized outcome, and duration. Do not force operators to infer which plugin failed from a generic CarrierException.

Wait for evidence before building the framework

The first carrier implementation does not reveal the stable contract. The second shows one difference. The third may show whether that difference is an axis or coincidence.

Before extracting an extension point, inspect:

  • which files changed for the last variants;
  • which conditionals repeat the same selection;
  • which behavior and failure semantics are actually shared;
  • which dependencies vary; and
  • what future case is already known rather than merely imaginable.

Refactor under characterization tests. Michael Feathers’s legacy-code change guidance asks how a change will be shown to work and how existing behavior will be shown not to have broken. Capture the old use case at its public boundary before introducing the registry: successful label identity, translated authentication failure, unsupported carrier behavior, and any retry decision the use case already owns. Move one branch behind the contract, then another. Delete the switch only when those tests still pass.

The adapter contract tests answer a different question. They prove that every new carrier obeys the application contract after the seam exists. Characterization tests protect the migration; contract tests protect future extensions. Treating one as the other leaves either the extraction or the next adapter unproved.

OCP succeeds when the next instance of an observed variation is local and the stable policy remains untouched. It fails when every class is wrapped in a factory for hypothetical futures. Close code against the change that keeps happening, and leave the rest easy to edit.