Liskov Substitution Principle is often explained through inheritance, but in modern PHP it is also easy to see through interfaces.
If code in ICanUp depends on a contract, I want to be able to provide another valid implementation of that contract without rewriting the caller because of surprising behavior.
In practice, the important part is not the implements keyword itself. What matters is whether the replacement preserves the caller's expectations.
Liskov Substitution Principle
Liskov Substitution Principle: Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.
In simpler PHP terms: if a caller works with an abstraction, another valid implementation of that abstraction should not break the caller's expectations.
Example 1 - SearchProvider Is Truly Replaceable
interface SearchProviderInterface{ public function type(): SearchTypeEnum; public function count(string $query): int; public function search( string $query, int $perPage = 12, ): LengthAwarePaginator;}Public search has PostSearchProvider, CategorySearchProvider, NewsSearchProvider, and TipSearchProvider.
They execute different Eloquent queries, but from SearchPage's point of view they behave consistently: they return their type, result count, and a LengthAwarePaginator.
public function results(): ?LengthAwarePaginator{ if ($this->query === '') { return null; } if (! $this->resultsResolved) { $this->results = $this->activeProvider->search( $this->query, ); $this->resultsResolved = true; } return $this->results;SearchPage has no separate branch for every provider. It does not ask whether this is Post or News. It simply works with SearchProviderInterface.
That is the simplest LSP check in this case: replacing the concrete provider does not force the caller to change how it works.
The Test Can Substitute a Fake Implementation
final class SearchProviderFake implements SearchProviderInterface{ public function __construct( private readonly SearchTypeEnum $searchType, private readonly int $resultTotal, ) {} public function type(): SearchTypeEnum { return $this->searchType; } public function count(string $query): int { return $this->resultTotal;In ICanUp tests, the same SearchPage works with a Fake provider that does not exist in production.
The Fake does not need to extend PostSearchProvider or know its internal query logic. It only needs to satisfy the behavioral contract expected by the caller.
This makes substitutability very visible.
Example 2 - A Cached Analytics Repository Remains Substitutable
interface AnalyticsAggregationRepositoryInterface{ public function aggregate( AnalyticsAggregationQuery $query, ): iterable;} final readonly class ContentAnalyticsQuery{ public function __construct( private AnalyticsAggregationRepositoryInterface $aggregationRepository, private AnalyticsPeriodResolver $periodResolver, ) {}}ContentAnalyticsQuery depends on AnalyticsAggregationRepositoryInterface, not on the MySQL implementation.
Real aggregates can be read by MySqlAnalyticsAggregationRepository, but the caller can also receive CachedAnalyticsAggregationRepository.
final readonly class CachedAnalyticsAggregationRepository implements AnalyticsAggregationRepositoryInterface{ public function __construct( private AnalyticsAggregationRepositoryInterface $source, private AnalyticsAggregationCache $cache, ) {} public function aggregate( AnalyticsAggregationQuery $query, ): iterable { return $this->cache->remember( $query, fn (): iterable => $this->source->aggregate($query), );The cached repository adds behavior, but the caller does not need to know that a cache exists.
For the same AnalyticsAggregationQuery, it still returns AnalyticsAggregatePoint values through the same iterable contract. From the caller's point of view, the contract is preserved.
This example also shows that LSP does not require classic inheritance. In PHP, substitutability is often built through interfaces and composition.
Example 3 - The Same Interface Is Not Enough
interface ThreadResolverInterface{ public function resolve( ThreadContextData $context, ): Thread;}The Communication Center has several resolvers: private, group, support, feedback, and post discussion. All of them expose the same resolve(ThreadContextData $context): Thread method.
This is where it becomes clear why a matching method signature alone is not enough.
public function resolve( ThreadContextData $context,): Thread { return DB::transaction( static function () use ($context): Thread { $subject = $context->subject(); if ($subject === null) { throw new InvalidArgumentException( __('validation.communication.post_discussion.subject_required') ); } // Resolve or create the discussion thread. } );PostDiscussionThreadResolver has an additional expectation: the context must contain a subject. Without it, the resolver throws InvalidArgumentException.
So the statement "every ThreadResolver is interchangeable for every ThreadContextData" would be incorrect.
This does not mean the current architecture is broken. In the real flow, the factory first selects a resolver for the ThreadTypeEnum and only then passes the context to it.
$resolver = $this->threadResolverFactory ->make($data->threadContext->type); $thread = $resolver->resolve( $data->threadContext,);For me, this is the most useful LSP lesson: look not only at the interface, but also at the real assumptions made by the caller and the implementation.
What Can Break Substitutability
In practical code, I check four simple things:
- Input. Does one implementation require more than the contract promises?
- Output. Does it return what the caller expects?
- Exceptions. Does normal input suddenly produce a new unexpected failure?
- Behavior. Does the caller need a new
instanceof,if, or special-case after the replacement?
If the caller needs to know which concrete implementation it received, substitutability deserves another look.
LSP Is Not Only About Inheritance
These examples barely use the classic parent class -> child class shape.
Instead, they show what matters more in a real Laravel project: a caller depends on an abstraction and several implementations promise behavior compatible with the same contract.
That is where Liskov Substitution Principle becomes a practical design rule rather than an academic one.
Practical Takeaway
My simplest formulation of Liskov Substitution Principle is: if code works through an abstraction, replacing one valid implementation with another should not force the caller to change its rules.
SearchProviderInterface shows clean substitutability. The Analytics repository shows the same idea through a Decorator and composition. ThreadResolverInterface reminds us that an identical method signature does not describe the whole behavioral contract.



