Liskov Substitution Principle часто пояснюють через наслідування, але в сучасному PHP його дуже зручно побачити через інтерфейси.
Якщо код у ICanUp залежить від певного contract, я хочу мати можливість підставити іншу коректну реалізацію цього contract і не переписувати код через несподівану поведінку.
На практиці тут важливий не сам implements. Важливо, чи зберігає нова реалізація очікування коду, який її використовує.
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.
Простіше для PHP: якщо код працює з абстракцією, інша коректна реалізація цієї абстракції не повинна ламати його очікування.
Приклад 1 - SearchProvider справді можна підміняти
interface SearchProviderInterface{ public function type(): SearchTypeEnum; public function count(string $query): int; public function search( string $query, int $perPage = 12, ): LengthAwarePaginator;}У публічному пошуку є PostSearchProvider, CategorySearchProvider, NewsSearchProvider і TipSearchProvider.
Вони виконують різні Eloquent queries, але для SearchPage поводяться однаково: повертають свій тип, кількість результатів і 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 не має окремої гілки для кожного provider. Він не питає: "це Post чи News?". Він просто працює з SearchProviderInterface.
Це і є найпростіша перевірка LSP у цьому кейсі: заміна конкретного provider не змушує код змінювати спосіб роботи.
Тест підставляє навіть Fake реалізацію
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;У тестах ICanUp той самий SearchPage працює з Fake provider, якого немає в production.
Fake не повинен наслідувати PostSearchProvider або знати його внутрішню логіку. Достатньо виконати поведінковий contract, який очікує код.
Це дуже наочний приклад взаємозамінності.
Приклад 2 - кешований Analytics repository теж залишається взаємозамінним
interface AnalyticsAggregationRepositoryInterface{ public function aggregate( AnalyticsAggregationQuery $query, ): iterable;} final readonly class ContentAnalyticsQuery{ public function __construct( private AnalyticsAggregationRepositoryInterface $aggregationRepository, private AnalyticsPeriodResolver $periodResolver, ) {}}ContentAnalyticsQuery залежить від AnalyticsAggregationRepositoryInterface, а не від MySQL реалізації.
Реальні aggregates може читати MySqlAnalyticsAggregationRepository. Але код може отримати й 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), );Кешований repository додає поведінку, але код, який його використовує, не повинен знати про кеш.
Для того самого AnalyticsAggregationQuery він так само повертає набір AnalyticsAggregatePoint через iterable. Тобто зовнішній contract зберігається.
Цей приклад корисний ще й тим, що LSP не вимагає класичного наслідування. У PHP взаємозамінність часто будується через interface і composition.
Приклад 3 - однаковий interface ще не гарантує взаємозамінність
interface ThreadResolverInterface{ public function resolve( ThreadContextData $context, ): Thread;}У Communication Center є кілька resolver-ів: private, group, support, feedback і post discussion. Усі вони мають однаковий метод resolve(ThreadContextData $context): Thread.
Але тут добре видно, чому самого signature недостатньо.
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 має додаткове очікування: у context обов’язково повинен бути subject. Якщо його немає, resolver кидає InvalidArgumentException.
Тому твердження "усі ThreadResolver взаємозамінні для будь-якого ThreadContextData" було б неправильним.
Це не означає, що поточна архітектура зламана. У реальному flow factory спочатку вибирає resolver відповідно до ThreadTypeEnum, а вже потім передає йому context.
$resolver = $this->threadResolverFactory ->make($data->threadContext->type); $thread = $resolver->resolve( $data->threadContext,);Для мене це найкорисніший урок LSP: дивитися потрібно не тільки на interface, а й на реальні припущення коду, який використовує contract, і самої реалізації.
Що може зламати взаємозамінність
У практичному коді я перевіряю чотири прості речі:
- Input. Чи не вимагає одна реалізація більше, ніж обіцяє contract?
- Output. Чи повертає вона те, на що розраховує код?
- Exceptions. Чи не з’являється нова несподівана помилка для нормального input?
- Behavior. Чи не доводиться після підміни додавати
instanceof,ifабо special-case?
Якщо після заміни реалізації код змушений знати, який саме конкретний клас він отримав, взаємозамінність варто перевірити уважніше.
LSP - не просто про наслідування
У цих прикладах майже немає класичної пари parent class -> child class.
Зате є те, що для реального Laravel-проєкту важливіше: код залежить від абстракції, а кілька реалізацій обіцяють поводитися відповідно до одного contract.
Саме на цій межі Liskov Substitution Principle стає практичним, а не академічним правилом.
Практичний висновок
Моє найпростіше формулювання Liskov Substitution Principle: якщо код працює через абстракцію, підміна однієї коректної реалізації іншою не повинна змушувати код, який її використовує, змінювати свої правила.
SearchProviderInterface показує чисту взаємозамінність. Analytics repository показує те саме через Decorator і composition. А ThreadResolverInterface нагадує, що однаковий method signature ще не описує весь поведінковий contract.



