0% прочитано

SOLID. Principle 3 - Liskov Substitution Principle in Practice in a Real Laravel Project

Using three real ICanUp cases, I explain Liskov Substitution Principle: when implementations are truly interchangeable through one contract, why a Decorator can remain substitutable, and why sharing an interface does not automatically guarantee compatible behavior.

23 вересня 2026 р. 10 хв читанняLaravel

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

PHP - SearchProviderInterface
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.

PHP - SearchPage uses the contract
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

PHP - SearchProvider fake used in tests
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

PHP - Analytics repository contract and caller
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.

PHP - 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

PHP - ThreadResolverInterface
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.

PHP - PostDiscussionThreadResolver precondition
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.

PHP - MessageService selects the matching resolver
$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.