A carrier integration often begins with one generous interface:
interface Carrier
{
public function quote(Shipment $shipment): ShippingQuote;
public function buyLabel(Shipment $shipment): Label;
public function cancelLabel(LabelId $labelId): void;
public function track(TrackingNumber $number): TrackingStatus;
public function bookPickup(PickupRequest $request): Pickup;
public function closeManifest(LocalDate $date): Manifest;
}The checkout page needs only quote(). A tracking job needs only track().
One carrier may not support pickups; another may not use manifests. The broad
interface makes every consumer and implementation participate in capabilities
they do not share.
The Interface Segregation Principle asks for client-specific contracts. It does not demand one method per interface. Split where clients, implementations, or change reasons diverge.
Unsupported methods reveal a false role
This implementation compiles and lies:
final class ParcelLockerCarrier implements Carrier
{
public function bookPickup(PickupRequest $request): Pickup
{
throw new LogicException('Parcel lockers do not support pickups.');
}
// Five more methods, supported or otherwise.
}The type says every Carrier can book a pickup. Runtime behavior says callers
must know which carrier they received before using that method. The abstraction
has moved a conditional into an exception.
Optional return types do not repair the contract:
public function bookPickup(PickupRequest $request): ?Pickup;null now conflates unsupported capability, unavailable slot, and perhaps a
failed request. The client still depends on a method irrelevant to many
implementations.
Define capabilities in the language of their clients:
interface ShippingQuotes
{
public function quote(Shipment $shipment): ShippingQuote;
}
interface ShippingLabels
{
public function buyLabel(Shipment $shipment): Label;
public function cancelLabel(LabelId $labelId): void;
}
interface ShipmentTracking
{
public function track(TrackingNumber $number): TrackingStatus;
}
interface PickupBookings
{
public function bookPickup(PickupRequest $request): Pickup;
}PHP allows a class to implement several interfaces, so an adapter can promise only the capabilities its provider supports:
final class ParcelLockerAdapter implements
ShippingQuotes,
ShippingLabels,
ShipmentTracking
{
// No pickup promise to violate.
}The checkout use case depends on ShippingQuotes, so adding a manifest method
cannot affect its source contract, test doubles, or wiring.
Robert Martin summarizes ISP as keeping interfaces small enough that users do not depend on things they do not need in his discussion of SOLID’s continued relevance. “Small” is relative to a client role, not an arbitrary method limit.
In ports-and-adapters terms, these client-owned interfaces are application ports. Dependency inversion decides which side owns them; interface segregation stops one port from becoming a renamed copy of the provider’s entire API.
One cohesive interface can have several methods
Splitting this interface would make one transactional capability harder to express:
interface Orders
{
public function getForUpdate(OrderId $id): Order;
public function save(Order $order): void;
}A command handler needs both operations to load and persist an aggregate. They
share ownership, lifecycle, and implementation. Creating OrderReaders and
OrderWriters solely to reach one method each may force two dependencies that
must always be used together.
Likewise, a payment gateway may coherently expose authorize, capture, and
refund when every client is a payment workflow and every implementation
supports the same lifecycle. Split it when a reporting client needs only
lookup, or when some providers cannot honor part of the contract.
For this example, the evidence is client usage:
Client Methods used
Checkout quote
Shipment lifecycle buyLabel, cancelLabel
Tracking poller track
Warehouse pickup bookPickup
Carrier settlement closeManifestWhen the rows differ, one Carrier role is probably fictional.
Provider-shaped interfaces spread external change
SDKs tend to group every endpoint under one client. Copying that shape into an application contract gives vendor organization control over internal dependencies:
interface AcmeSdk
{
public function rates(...): array;
public function labels(...): array;
public function pickups(...): array;
public function manifests(...): array;
}Application-facing interfaces should describe required capabilities and return application types. One adapter can implement several of them while sharing an internal authenticated SDK client:
final class AcmeCarrierAdapter implements
ShippingQuotes,
ShippingLabels,
PickupBookings
{
public function __construct(private AcmeClient $client) {}
}Sharing one adapter centralizes authentication and transport configuration. It does not prove that every request uses one network connection; connection reuse depends on the HTTP client and handler. Separating public roles avoids making every consumer depend on the SDK’s full surface. Interface segregation does not require duplicating infrastructure.
Segregation changes wiring and discovery
Laravel’s container API documents shared bindings and type aliases, so the same adapter can resolve through several contracts:
$this->app->singleton(AcmeCarrierAdapter::class);
$this->app->alias(AcmeCarrierAdapter::class, ShippingQuotes::class);
$this->app->alias(AcmeCarrierAdapter::class, ShippingLabels::class);
$this->app->alias(AcmeCarrierAdapter::class, PickupBookings::class);When several carrier adapters implement one capability, inject a registry or
use Laravel’s tagged bindings for that capability. Do not
inject every carrier and probe it with method_exists(); that recreates an
implicit broad interface.
More interfaces do create more names and bindings. That cost is justified when they isolate clients from real changes or unsupported operations. If a module has one implementation, one client, and one cohesive capability, a concrete class may be clearer than any interface.
Contract tests follow each capability
Do not run pickup tests against an adapter that never promised pickups. Run
the shipping-quote contract against every ShippingQuotes implementation:
function shippingQuotesContract(Closure $quotes): void
{
it('returns money in the shipment currency', function () use ($quotes) {
$shipment = aShipment(currency: Currency::EUR);
$quote = $quotes()->quote($shipment);
expect($quote->price->currency)->toBe(Currency::EUR);
});
it('normalizes unavailable services', function () use ($quotes) {
expect(fn () => $quotes()->quote(anUnsupportedShipment()))
->toThrow(ShippingServiceUnavailable::class);
});
}Run a separate PickupBookings contract only against pickup-capable adapters.
The test suite now mirrors truthful substitutability instead of carrying skip
flags for unsupported methods.
Client tests also become smaller. Checkout can use a FixedShippingQuotes
fake without implementing label, tracking, pickup, and manifest methods that
the scenario never touches.
Split from observed clients
Before changing a broad interface:
- list its clients and the methods each calls;
- list implementations and unsupported or exceptional methods;
- group methods that serve the same client role and change together;
- introduce one capability interface beside that client;
- let existing adapters implement it; and
- migrate callers before removing the broad contract.
Do not split from method names alone. Two methods may protect one lifecycle; five may serve unrelated applications.
An interface is successful when a client can trust every operation it sees and ignore capabilities it never requested. Make roles as broad as their real clients and no broader.