Understanding Inheritance and Composition in PHP Object-Oriented Programming
In PHP object-oriented programming, two primary patterns shape how classes relate to each other: inheritance and composition. Each approach offers a different way to reuse code, but choosing between them affects how flexible, maintainable, and testable your applications become over time. Many PHP developers default to inheritance without considering the trade-offs, which often leads to rigid class hierarchies that become difficult to change as a project grows.
This guide walks through both approaches with practical PHP examples, explains when each pattern works well, and provides clear guidelines to help you decide in your own projects. Whether you are building a small application or maintaining a larger codebase, understanding these patterns is fundamental to writing clean object-oriented code.
What Inheritance Is in PHP
Inheritance creates an "is-a" relationship between classes. When a PHP class extends another, it automatically gains access to the parent class properties and methods. The subclass represents a specialised type of the parent class, and PHP's type system recognises this relationship.
Consider a straightforward example where a dog is a type of animal:
class Animal
{
protected string $name;
public function __construct(string $name)
{
$this->name = $name;
}
public function eat(): void
{
echo "{$this->name} is eating.";
}
}
class Dog extends Animal
{
public function bark(): void
{
echo "{$this->name} says woof.";
}
}
The Dog class inherits the $name property and eat() method from Animal. You can create a dog instance and call both methods without redefining them:
$dog = new Dog('Rex');
$dog->eat(); // Outputs: Rex is eating.
$dog->bark(); // Outputs: Rex says woof.
This approach works well when the relationship genuinely matches "a dog is an animal". The inheritance chain reflects real-world categorisation, and the code duplication avoided is genuine.
What Composition Is in PHP
Composition creates a "has-a" relationship instead of an "is-a" relationship. A class contains another class to use its behaviour, but the contained class is a separate entity with its own identity. This approach favours delegation over code inheritance.
Using the same analogy, a car has an engine rather than being a type of engine:
class Engine
{
public function start(): void
{
echo "Engine started.";
}
}
class Car
{
private Engine $engine;
public function __construct()
{
$this->engine = new Engine();
}
public function drive(): void
{
$this->engine->start();
echo " Car is moving.";
}
}
The Car class does not inherit from Engine. Instead, it contains an engine instance and delegates the starting behaviour to it. This distinction matters because the relationship between car and engine is fundamentally different from the relationship between dog and animal.
When Inheritance Works Well
Inheritance remains the right choice in several specific scenarios. Understanding these cases helps you use inheritance deliberately rather than as a default.
Genuine Type Hierarchies
Inheritance works best when the subclass genuinely is a specialised version of the parent class and when the inheritance chain stays shallow. One or two levels of inheritance rarely cause problems. Beyond that, the complexity increases significantly.
A strong example is PHP's exception handling system. Exception hierarchies naturally form a type specialisation tree:
class AppException extends Exception {}
class ValidationException extends AppException {}
class DatabaseException extends AppException {}
class NetworkException extends AppException {}
A ValidationException is an AppException, which is an Exception. The relationship is clear, natural, and useful. PHP's type system allows you to catch specific exceptions or broader parent exceptions, and this flexibility is appropriate for the domain.
Framework Base Classes
When working with frameworks that provide established class hierarchies, extending their base classes is often the correct approach. If you use an MVC framework, your controller classes likely extend a base Controller class provided by the framework. Similarly, CLI applications built on frameworks like Symfony Console often extend a base Command class.
In these cases, the framework provides the contract and structure, and your class fills in the specifics. The inheritance hierarchy is stable because it is controlled by the framework maintainers, not your application code.
When Inheritance Causes Problems
The "gorilla banana" problem is a well-known criticism of inheritance. You wanted a banana, but you inherited a gorilla holding a banana, the entire jungle, and everything that comes with them. This analogy describes how inheriting from a parent class brings along behaviour you may not want or need.
Deep inheritance chains become rigid and difficult to modify. Changing behaviour in the middle of a chain risks breaking everything below it. Consider a shape hierarchy that tries to account for every combination of properties:
class Shape {}
class ColoredShape extends Shape {}
class FilledShape extends ColoredShape {}
class BorderedShape extends FilledShape {}
Each layer adds a dimension of variation. But what happens when you need a shape that is filled and bordered without colour? The hierarchy forces combinations you did not plan for, leading to either class explosion or awkward workarounds.
This problem is called diagonal inheritance, and it is a reliable signal that composition is the better model for your situation.
When Composition Works Well
Composition shines when flexibility, interchangeability, and testability matter more than convenience. With composition, you can swap out dependencies without changing the class that uses them, and you can test each component in isolation.
Dependency Injection and Interchangeable Components
Consider a logging system where your application needs to write logs to different destinations depending on the environment:
interface Logger
{
public function log(string $message): void;
}
class FileLogger implements Logger
{
public function __construct(private string $path) {}
public function log(string $message): void
{
file_put_contents($this->path, $message . PHP_EOL, FILE_APPEND);
}
}
class DatabaseLogger implements Logger
{
public function __construct(private PDO $pdo) {}
public function log(string $message): void
{
$stmt = $this->pdo->prepare('INSERT INTO logs (message) VALUES (?)');
$stmt->execute([$message]);
}
}
class Application
{
private Logger $logger;
public function __construct(Logger $logger)
{
$this->logger = $logger;
}
public function run(): void
{
$this->logger->log('Application started.');
}
}
The Application class does not care whether the logger writes to a file or a database. During testing, you can pass a mock logger or a NullLogger implementation. In production, you pass a real logger configured for your infrastructure. This is dependency injection, and it is the foundation of testable PHP code.
Runtime Behaviour Configuration
Composition allows you to determine behaviour at runtime rather than at class definition time. This flexibility is particularly valuable for features that need to change based on user input, configuration, or environmental factors.
As your application grows, you may find that classes which use composition are easier to extend and modify than those relying on inheritance. The PHP 8.3 changes introduced features like readonly classes that complement composition patterns well, making it cleaner to define immutable data objects that can be passed between components.
The Strategy Pattern and Composition
Many design patterns rely on composition rather than inheritance. The Strategy pattern is a clear example of how composition enables flexible, extensible code.
Imagine an e-commerce product that applies different discount strategies based on customer type, promotion rules, or time of year:
interface DiscountStrategy
{
public function apply(float $price): float;
}
class NoDiscount implements DiscountStrategy
{
public function apply(float $price): float
{
return $price;
}
}
class PercentageDiscount implements DiscountStrategy
{
public function __construct(private float $percent) {}
public function apply(float $price): float
{
return $price * (1 - $this->percent / 100);
}
}
class FixedDiscount implements DiscountStrategy
{
public function __construct(private float $amount) {}
public function apply(float $price): float
{
return max(0, $price - $this->amount);
}
}
class Product
{
public function __construct(
private string $name,
private float $basePrice,
private DiscountStrategy $discountStrategy
) {}
public function getPrice(): float
{
return $this->discountStrategy->apply($this->basePrice);
}
}
You can change the pricing strategy at runtime without modifying the Product class. New discount types require a new class implementing DiscountStrategy, not a new subclass of Product. This separation of concerns makes the code easier to test, extend, and maintain.
Similar patterns appear in other areas of PHP development. API rate limiting, for example, benefits from composition where different throttling strategies can be applied to the same endpoint handler without modifying the core code.
Guidelines for Choosing Between Inheritance and Composition
Making the right choice between inheritance and composition depends on understanding the trade-offs and applying practical judgment to each situation.
- Use inheritance when there is a clear, stable "is-a" relationship that will not need to change frequently. Exception hierarchies, framework base classes, and domain models with genuine type specialisation are good candidates where inheritance simplifies the code.
- Use composition when behaviour should be configurable, swappable, or determined at runtime. When you find yourself creating base classes with empty methods that subclasses override "just in case", that is a composition problem wearing an inheritance disguise.
- Watch for diagonal inheritance where inheritance chains start combining in multiple dimensions to handle variations. This pattern signals that composition handles the variation more cleanly.
- Apply the parent class test: if you would need to change the parent class to add a feature that only applies to one subclass, you probably need composition instead. Parent classes should not need to know about subclasses.
- Keep inheritance chains shallow: one or two levels of inheritance rarely cause problems. Three or more levels should prompt you to question whether composition would serve better.
- Consider testability: classes that are hard to test in isolation often indicate a composition opportunity. If a class creates its own dependencies internally using
new SomeClass()directly within methods, refactoring to accept dependencies through the constructor makes testing straightforward.
Practical Example: Refactoring Toward Composition
Imagine a User class that sends notifications through email and SMS. An inheritance approach might look like this:
class User
{
protected string $email;
protected string $phone;
protected bool $sendEmail = true;
protected bool $sendSms = false;
public function sendNotification(string $message): void
{
if ($this->sendEmail) {
mail($this->email, 'Notification', $message);
}
if ($this->sendSms) {
// SMS sending logic
}
}
}
As notification channels grow, you add more flags and nested conditionals. A composition approach scales more gracefully:
interface Notifier
{
public function notify(string $message): void;
}
class EmailNotifier implements Notifier
{
public function __construct(private string $address) {}
public function notify(string $message): void
{
mail($this->address, 'Notification', $message);
}
}
class SmsNotifier implements Notifier
{
public function __construct(private string $phone) {}
public function notify(string $message): void
{
// SMS sending logic
}
}
class User
{
/** @var array<Notifier> */
private array $notifiers = [];
public function addNotifier(Notifier $notifier): void
{
$this->notifiers[] = $notifier;
}
public function sendNotification(string $message): void
{
foreach ($this->notifiers as $notifier) {
$notifier->notify($message);
}
}
}
Adding a new notification channel requires a new class implementing Notifier, not modifying the User class. Each notifier is testable in isolation, and the User class remains focused on user-related concerns.
Common Mistakes to Avoid
Understanding common pitfalls helps you write better object-oriented code from the start.
Reusing code through inheritance when composition would be cleaner is a frequent mistake. Developers often inherit simply because it avoids writing code, not because the relationship is genuinely "is-a". The result is tight coupling to a parent class that may change in ways that break subclasses unexpectedly.
Creating deep inheritance hierarchies to model every variation in a domain is another common error. As the domain grows, these hierarchies become unmanageable. Composition models variations through interchangeable components, which scales more predictably.
Relying on inheritance for code reuse rather than for polymorphism is a subtle mistake. Inheritance is most powerful when it enables treating different types uniformly through a common interface. Using it merely to share code between unrelated classes usually indicates that composition better serves the design.
Building Maintainable PHP Applications
Both inheritance and composition are tools with distinct purposes in PHP object-oriented programming. Inheritance works best for stable "is-a" relationships that reflect genuine type hierarchies, while composition excels when you need flexibility, interchangeable components, and testable code.
The patterns described here support long-term maintainability in PHP projects. Whether you are working with a small script or a larger application, applying these principles helps you build code that adapts to change without requiring constant restructuring.
If you are working through a design decision in a PHP project and want a practical review of your current architecture, you can get in touch with details of your setup and the specific challenges you are facing.