Understanding Inheritance and Composition in PHP Object-Oriented Programming
Choosing between inheritance and composition is one of the most common design decisions in PHP object-oriented programming. Both approaches let you reuse code, but they shape your application architecture in very different ways. The choice affects how easily you can extend your code, test individual components, and adapt to changing requirements over time.
Many PHP developers reach for inheritance by default because it requires less initial setup. This habit often leads to rigid class hierarchies that become difficult to modify as a project grows. Understanding when each pattern genuinely serves your project helps you make better architectural decisions from the start.
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 naturally.
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. If you are exploring when to structure classes this way, a practical guide on PHP OOP and when to use classes covers this decision in more detail.
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, which leads to more flexible and testable designs.
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 pattern.
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 and the risks of tight coupling grow.
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. This kind of clear type hierarchy is where inheritance genuinely simplifies code rather than complicating it.
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. This separation means you benefit from inheritance without owning the maintenance burden of the parent class.
When Inheritance Causes Problems
The "gorilla banana" problem is a well-known criticism of inheritance in object-oriented programming. 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 can bring along behaviour you do 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. Well-structured composition also supports separation of concerns, keeping your code organised as it grows.
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. Modern PHP versions support patterns that make it cleaner to define immutable data objects and flexible component interactions.
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. This pattern appears frequently when building APIs and business applications where different processing strategies need to be applied to the same data.
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 over time.
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. These guidelines help you evaluate your options before committing to a particular approach.
- 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 without introducing tight coupling.
- 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 and with less maintenance overhead.
- 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 or their specialised requirements.
- 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 and reduce the risk of unintended side effects.
- 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 and your code more maintainable.
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. The class becomes harder to follow and each new channel requires modifying the same method. 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. This separation also makes it easier to add or remove notification channels without touching existing code.
Common Mistakes to Avoid
Understanding common pitfalls helps you write better object-oriented code from the start and avoid refactoring work later.
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. Before reaching for inheritance, ask yourself whether the relationship truly represents a type specialisation or whether you are simply avoiding code duplication.
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 and with less risk of breaking existing functionality.
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 and produces more maintainable results.
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. Understanding the trade-offs between these approaches lets you choose confidently based on your specific situation rather than following patterns blindly.
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.
Related practical reading
These related guides can help you connect this topic with the wider website, server, security, and support decisions around it.
- GraphQL in PHP vs REST: When GraphQL Is the Better Choice - useful background for related development decisions
- PHP 8.4: Property Hooks and Asymmetric Visibility - useful background for related development decisions