Objects Namespaces And Application Architecture
Inheritance
Inheritance lets one class extend another class. The child class receives the parent's public and protected behaviour and can add or override behaviour.
Inheritance is useful when there is a true "is a kind of" relationship and the child can safely be used wherever the parent is expected. It is risky when it is used only to share code, because parent and child classes can become tightly coupled.
Extend A Parent Class
Use extends to create a child class.
<?php
declare(strict_types=1);
class Notification
{
public function summary(): string
{
return 'Notification ready';
}
}
class EmailNotification extends Notification
{
public function recipientType(): string
{
return 'email';
}
}
$notification = new EmailNotification();
echo $notification->summary() . PHP_EOL;
echo $notification->recipientType() . PHP_EOL;
// Prints:
// Notification ready
// email
EmailNotification can call the inherited summary() method because it extends Notification.
Override Behaviour
A child class can replace a parent method with its own implementation.
<?php
declare(strict_types=1);
class Report
{
public function title(): string
{
return 'Report';
}
}
class SalesReport extends Report
{
public function title(): string
{
return 'Sales Report';
}
}
$report = new SalesReport();
echo $report->title() . PHP_EOL;
// Prints:
// Sales Report
Overriding is powerful, but it means callers may get different behaviour depending on the concrete child class.
Call Parent Behaviour
Use parent:: when a child wants to reuse part of the parent's method.
<?php
declare(strict_types=1);
class BaseEmail
{
public function subject(): string
{
return 'Account update';
}
}
class SecurityEmail extends BaseEmail
{
public function subject(): string
{
return '[Security] ' . parent::subject();
}
}
$email = new SecurityEmail();
echo $email->subject() . PHP_EOL;
// Prints:
// [Security] Account update
This keeps common behaviour in one place while still allowing a child to specialise it.
Child Objects Can Be Used As Parent Objects
Code that accepts a parent type can receive a child object.
<?php
declare(strict_types=1);
class Message
{
public function body(): string
{
return 'Message body';
}
}
class WelcomeMessage extends Message
{
public function body(): string
{
return 'Welcome to the application';
}
}
function printMessage(Message $message): void
{
echo $message->body() . PHP_EOL;
}
printMessage(new WelcomeMessage());
// Prints:
// Welcome to the application
This only works well when the child honours the expectations of the parent type.
Avoid Inheritance For Simple Code Sharing
If two classes merely need the same helper, inheritance may be the wrong tool.
<?php
declare(strict_types=1);
class MoneyFormatter
{
public function formatPennies(int $pennies): string
{
return '£' . number_format($pennies / 100, 2);
}
}
$formatter = new MoneyFormatter();
echo $formatter->formatPennies(2499) . PHP_EOL;
// Prints:
// £24.99
Passing a helper object is often clearer than making unrelated classes extend a shared base class.
Watch Fragile Base Classes
Changes to a parent class can unexpectedly affect every child class. This is why deep inheritance trees are hard to maintain.
<?php
declare(strict_types=1);
class Importer
{
public function sourceType(): string
{
return 'generic';
}
}
class CsvImporter extends Importer
{
public function sourceType(): string
{
return 'csv';
}
}
echo (new CsvImporter())->sourceType() . PHP_EOL;
// Prints:
// csv
Keep inheritance shallow and purposeful.
What To Remember
Inheritance models "is a kind of" relationships. Use it when child objects can safely stand in for parent objects. Prefer composition when the goal is sharing services, helpers, or changeable behaviour.
Inheritance As A Behavioral Contract
Inheritance is useful only when a child object can stand in for its parent without surprising the caller. Matching method names and PHP types is necessary, but it is not sufficient. The parent class also establishes behavioral expectations: which inputs are accepted, which exceptions are normal, whether a method changes state, and what remains true afterward.
Suppose a parent payment type promises that capture() accepts every positive amount up to an authorized limit and returns one stable capture reference. A child implementation is not substitutable if it rejects ordinary positive amounts because its provider supports only whole units, silently captures twice during a retry, or returns a temporary request ID instead of the promised capture reference. All three children could satisfy the PHP method signature while violating the contract.
Write important behavioral guarantees in tests and documentation close to the parent abstraction. A useful contract describes:
- valid and invalid inputs;
- state transitions;
- return-value meaning;
- expected exception categories;
- idempotency or retry behavior;
- externally visible side effects.
Shared contract tests can then run against each concrete subtype. Implementation-specific tests are still needed, but they should supplement rather than replace the common suite.
Preconditions And Postconditions
A child must not demand more from callers than the parent requires. If the parent accepts any non-empty email address, a child cannot require addresses from one company domain unless that restriction is part of the parent contract. Strengthening the precondition means code written correctly for the parent can fail when given the child.
A child must also preserve the parent's result guarantees. If the parent promises a persisted record after save(), a child that only queues an eventual write changes the postcondition. Eventual persistence may be a valid design, but it needs a different contract so callers can respond correctly.
Exceptions are part of this reasoning. Replacing a documented domain rejection with an arbitrary provider exception leaks implementation detail and can break error handling. Translate provider failures at the adapter boundary into application-owned outcomes, while retaining the original exception as diagnostic context where appropriate.
Prefer Composition For Independent Capabilities
Inheritance is a poor fit when variations combine independently. Consider a report that may be rendered as CSV or JSON, compressed or uncompressed, and stored locally or in object storage. A class hierarchy for every combination grows quickly and couples unrelated choices.
Composition keeps the dimensions separate:
<?php
declare(strict_types=1);
interface ReportEncoder
{
public function encode(array $rows): string;
}
interface ReportStore
{
public function put(string $name, string $contents): void;
}
final class ExportReport
{
public function __construct(
private ReportEncoder $encoder,
private ReportStore $store,
) {
}
public function export(string $name, array $rows): void
{
$this->store->put($name, $this->encoder->encode($rows));
}
}
The exporter does not need a subclass for each encoder and storage combination. Tests can provide focused fakes, and adding another store does not change the encoder hierarchy. Use inheritance when the relationship itself is meaningful; use composition when the goal is assembling capabilities.
Protected State Creates A Long-Term Contract
A protected property looks less public than a public property, but subclasses can depend on its name, type, mutation timing, and initialization order. Once third-party or distant application subclasses use it, changing that state becomes a compatibility problem.
Prefer private state with small protected methods when extension is intentional. The method can express a stable operation while the base class retains freedom to reorganize its data. Even protected methods should be few and documented. Every extension point increases the behavior the parent maintainer must preserve.
Avoid calling overridable methods from a constructor. The child portion of the object may not be initialized when the method runs, and the result can depend on subtle construction order. Establish base invariants first, then let normal public methods invoke variation after construction is complete.
Package Boundaries And Final By Default
Inheritance becomes riskier when child classes live outside the package that owns the base class. A protected method rename, a new call to an overridable method, or a changed constructor sequence can break code the parent maintainer cannot inspect.
Library authors should state whether a class is intended for extension. If it is, document supported hooks and treat them as part of semantic-versioning compatibility. If it is not, marking the class final makes the boundary explicit. Application code can also use final by default and open a deliberate interface or composition point when a real variation appears.
Framework base classes are sometimes designed for inheritance, but that does not make every framework class a safe parent. Read the framework's extension documentation, prefer supported interfaces and events, and avoid depending on undocumented protected internals.
A Practical Review Method
When reviewing a proposed inheritance relationship, ask:
- Can every child honor every public parent operation?
- Does any child reject input the parent accepts?
- Does any child weaken the meaning of a return value or side effect?
- Are callers checking concrete child types?
- Is protected state carrying an undocumented contract?
- Would composition express the variation with less coupling?
- Can one shared contract suite run against every child?
Concrete-type checks are a warning sign. A caller that accepts the parent but immediately branches with instanceof often knows the abstraction is not truly uniform. Sometimes capabilities genuinely differ; model that difference with separate interfaces or explicit result types instead of pretending one parent contract covers everything.
Verification
Create a shared test suite for the parent behavior and run it against every subtype. Include boundary inputs, state transitions, failures, and repeated calls. Add a regression test whenever a base-class change breaks a child.
Static analysis can verify compatible signatures, but behavioral tests must verify semantics. Mutation testing can also reveal weak contract tests by changing conditions or return values that the suite should notice.
Finally, compare the inheritance design with a composition design. The purpose is not to ban inheritance. It is to make the coupling visible and retain it only when the is-a relationship and shared lifecycle are genuine.
Evolving A Hierarchy Without Breaking Callers
Adding behavior to a published parent class is not automatically backward compatible. Imagine that every payment subtype implements authorize() and capture(), then the parent gains an abstract refund() method. Every existing child now fails to load until it implements the new operation. Giving the method a default implementation that throws an unsupported-operation exception avoids the immediate load failure, but it creates a weaker abstraction: callers still cannot rely on every payment object to refund.
A capability interface expresses the difference honestly:
<?php
declare(strict_types=1);
interface RefundablePayment
{
public function refund(int $amountInCents): RefundResult;
}
Code that needs refunds can require RefundablePayment; code that only captures payments can continue to depend on the narrower parent contract. This design prevents concrete-type checks from spreading through callers and lets an adapter acquire the capability in a later release.
PHP signature rules help preserve part of the contract. A child may accept a broader parameter type and return a narrower result type where variance rules permit it, but the runtime cannot prove business semantics. A child that accepts every Money object yet rounds unsupported currencies still violates the practical promise. Signature compatibility and substitutability must therefore be reviewed separately.
Stored or serialized child objects make hierarchy changes more dangerous. Renaming classes, moving namespaces, changing constructor assumptions, or removing inherited properties can break queue payloads, sessions, ORM hydration, and cached objects after deployment. Prefer explicit serialized messages with versioned fields instead of serializing rich domain objects across release boundaries. When legacy serialized data already exists, test deserialization using fixtures produced by the previous release.
Before changing a base class, list all known children, extension packages, persisted representations, and framework proxies. Run the shared contract suite against both built-in and third-party implementations where possible. An additive capability interface is often safer than expanding a parent whose consumers and subclasses no longer change together.
After this lesson, you should be able to define inheritance as a substitutability contract, recognize strengthened preconditions and weakened postconditions, limit protected extension surfaces, choose composition for independent capabilities, and test every subtype against shared behavioral guarantees.
Practice
Task: Specialise A Notification
Create a parent notification class and a specialised child class.
Requirements
- Use
declare(strict_types=1);. - Create a
Notificationparent class. - Give it a
channel()method that returnsgeneric. - Give it a
summary()method. - Create an
EmailNotificationchild class. - Override
channel()so it returnsemail. - Use a function that accepts
Notificationand prints its summary and channel. - Pass an
EmailNotificationinto that function. - Include the expected output as comments in the same PHP code block.
The example should show that a child object can be used where the parent type is expected.
Show solution
<?php
declare(strict_types=1);
class Notification
{
public function channel(): string
{
return 'generic';
}
public function summary(): string
{
return 'Notification ready';
}
}
class EmailNotification extends Notification
{
public function channel(): string
{
return 'email';
}
}
function printNotification(Notification $notification): void
{
echo $notification->summary() . ' via ' . $notification->channel() . PHP_EOL;
}
printNotification(new EmailNotification());
// Prints:
// Notification ready via email
EmailNotification extends Notification, so it can be passed to a function that asks for Notification. The child class specialises the channel without changing the function signature.