LSP violations — when inheritance is wrong
Concept
LSP violations are more common than developers realize, and they cause bugs that are uniquely hard to debug because they only appear when a concrete subtype is substituted — not when the code is first written. The violation is often invisible at the point of definition and only manifests in production or in integration tests.
The most common LSP violations in PHP codebases fall into three categories:
1. Exception contract violations — a subtype throws exceptions that the caller does not expect, or throws no exception when the contract says it must. A CachedUserRepository that swallows exceptions and returns null when the UserRepository interface promises it will either return a User or throw UserNotFoundException is an LSP violation.
2. Behavioural silencing — a subtype overrides a method with an empty body or a stub. This is extremely common in inheritance-based "partial implementations" where a developer extends a class but only cares about a subset of its methods. Callers assuming all methods do something meaningful will be silently broken.
3. Postcondition weakening — the most subtle form. The parent contract promises X; the subclass delivers less than X. A PremiumShippingCalculator extends ShippingCalculator that returns 0.0 for methods it does not support, when the ShippingCalculator contract promises a valid shipping cost, is delivering less than promised.
Understanding when inheritance is wrong is as important as understanding how to use it correctly. The rule of thumb: use inheritance when the child genuinely extends the parent's behavior without breaking any of its contracts. Use composition when the child needs some of the parent's behavior but cannot safely fulfill all of the parent's contracts.
Code Example
<?php
declare(strict_types=1);
// VIOLATION TYPE 1: Behavioural silencing via empty overrides
abstract class NotificationSender
{
abstract public function send(User $user, string $message): void;
abstract public function sendBulk(array $users, string $message): void;
}
class EmailSender extends NotificationSender
{
public function send(User $user, string $message): void
{
mail($user->email, 'Notification', $message);
}
// VIOLATION: silently does nothing — callers of NotificationSender
// have no way to know bulk sending is broken for this implementation
public function sendBulk(array $users, string $message): void
{
// Too lazy to implement, so... nothing
}
}
// CORRECT: Interface segregation instead of inheritance when behavior is partial
interface SingleNotificationSender
{
public function send(User $user, string $message): void;
}
interface BulkNotificationSender extends SingleNotificationSender
{
public function sendBulk(array $users, string $message): void;
}
// EmailSender only promises what it can deliver
class EmailSender implements SingleNotificationSender
{
public function send(User $user, string $message): void
{
mail($user->email, 'Notification', $message);
}
// No sendBulk — honest about its capabilities
}
// VIOLATION TYPE 2: Exception contract violation in a decorator
interface ProductRepository
{
/** @throws ProductNotFoundException */
public function findBySku(string $sku): Product;
}
class DatabaseProductRepository implements ProductRepository
{
public function findBySku(string $sku): Product
{
$product = Product::where('sku', $sku)->first();
if ($product === null) {
throw new ProductNotFoundException("SKU: {$sku}");
}
return $product;
}
}
// VIOLATION: CachingProductRepository changes the exception contract
class CachingProductRepository implements ProductRepository
{
public function __construct(private readonly DatabaseProductRepository $db) {}
public function findBySku(string $sku): Product
{
try {
return Cache::remember("product:{$sku}", 3600,
fn() => $this->db->findBySku($sku)
);
} catch (\Exception $e) {
// Swallows ProductNotFoundException, returns null implicitly via cache
// Return type says Product, but null might come through caching
// Callers expecting ProductNotFoundException will never catch it
}
}
}
// CORRECT: Caching decorator preserves the contract
class CachingProductRepository implements ProductRepository
{
public function __construct(
private readonly ProductRepository $inner,
private readonly CacheInterface $cache,
) {}
public function findBySku(string $sku): Product
{
$cacheKey = "product:{$sku}";
if ($this->cache->has($cacheKey)) {
return $this->cache->get($cacheKey);
}
// Let ProductNotFoundException propagate — preserve the contract
$product = $this->inner->findBySku($sku);
$this->cache->set($cacheKey, $product, 3600);
return $product;
}
}Interview Q&A
Q: Why is inheritance the wrong choice when you cannot fulfill the full contract of the parent?
Because inheritance makes a promise to every caller: "an instance of this subclass can be used anywhere the parent can be used." If you cannot keep that promise, the subclass is a lie — and callers will get surprising behavior when they depend on a parent contract that the subclass silently breaks. The alternative is composition: hold the parent (or similar) as a private dependency and expose only the subset of behavior you actually support through a narrower interface. This way you make no promise you cannot keep.
Q: Give an example of an LSP violation in a Laravel application with Eloquent.
A common one: a SoftDeletingRepository that extends a Repository base class. The base class's delete(int $id): void contract deletes the record permanently. The SoftDeletingRepository overrides delete() to soft-delete instead (sets deleted_at). Technically, callers that need hard deletes will silently get soft deletes — a broken postcondition. The better design is to treat soft-deleting as a different concern: either have an explicit softDelete() method on the repository, or design the repository interface to be explicit about whether its delete is permanent or reversible.
Q: How does PHP enforce LSP at the language level?
PHP enforces covariant return types and contravariant parameter types since 7.4. If a child class declares a return type that is a supertype of the parent's declared return type, PHP will throw a fatal error. Similarly, if the child narrows the parameter type (makes it more specific than the parent), PHP throws an error. These language-level checks catch the most obvious violations. What PHP cannot catch is behavioral violations — a subclass that returns the right type but computes the wrong value, or throws the wrong exception. Those require code review, tests, and contract-aware documentation.