Encapsulation and Inheritance: Protecting State, Extending Behavior
Understand encapsulation as a design discipline built on visibility, learn how extends lets one class inherit and specialize another's behavior, how method overriding and parent:: interact, and why final and Liskov-compatible method signatures matter for keeping class hierarchies safe to extend.
The previous lesson gave you the raw tools — public/protected/private, constructors, static members. This lesson is about *design*: how those tools combine into two of object-oriented programming's foundational ideas. Encapsulation is about hiding an object's internal details behind a deliberate, minimal public surface, so the object can guarantee its own invariants. Inheritance is about letting one class reuse and specialize another's behavior through an extends relationship, so you don't have to rewrite shared logic in every related class. Used well, the two work together: a base class encapsulates shared invariants, and subclasses inherit that protection while adding their own specialized behavior.
Encapsulation: hiding state behind a deliberate API
Encapsulation means an object controls its own state — outside code cannot reach into an object and set its properties to whatever it wants; it can only go through the methods the object chooses to expose. This is not about secrecy for its own sake, it's about *invariants*: rules that must always hold true for an object to make sense. A BankAccount should never have a negative balance from a plain deposit; an Order should never move from 'shipped' back to 'pending'. If every property were public, any code anywhere could violate those rules by direct assignment, and the class would have no way to stop it.
The standard encapsulation pattern is: make properties private (or protected, if subclasses legitimately need direct access), and expose behavior — not raw data — through public methods. Where read access to a value is genuinely needed, add a narrow getter (getBalance(): float) rather than making the property itself public; the getter can enforce read-only access even though the property is technically mutable internally.
Encapsulation is a spectrum, not a binary switch. public properties are appropriate for simple, invariant-free data bags (a Point with public float $x, $y has no rule to protect). The more a class's correctness depends on its properties staying consistent with each other, the more aggressively you should encapsulate.
protected: the middle ground for subclasses
protected visibility exists specifically for inheritance: a protected property or method is invisible to outside code, exactly like private, but *is* visible to any subclass. This lets a base class share internal implementation details with the classes that extend it, without exposing those details to the rest of the program.
A common design question is 'private or protected?'. Choose private when a subclass should have no reason to touch that state directly (it's a pure implementation detail even relative to subclasses). Choose protected when you expect subclasses to legitimately need it — for example, a base Shape class might keep protected float $x, $y for position, expecting subclasses like Circle and Rectangle to use those coordinates directly when computing their own geometry.
Inheritance with extends
A class inherits from another using extends: class Manager extends Employee {}. The subclass (Manager, also called the 'child' class) automatically gets every public and protected property and method the parent (Employee) declares, without having to redeclare any of them. This is inheritance's core value: shared behavior lives in one place (the parent), and each subclass only needs to write what makes it different.
PHP supports only single inheritance for classes — a class may extends exactly one parent (unlike interfaces, which a class may implement many of, covered in the next lesson). This is a deliberate constraint that avoids the ambiguity of 'which parent's version wins' that multiple class inheritance creates in other languages.
A subclass's constructor does not automatically call the parent's constructor. If Employee has a constructor that sets up required state, Manager's own constructor must explicitly call parent::__construct(...) — otherwise that parent setup never runs for Manager objects.
Overriding methods and calling parent::
A subclass can override an inherited method by redeclaring a method with the same name — the subclass's version replaces the parent's when called on a subclass object. This is how a Manager can customize Employee's annualBonus() calculation while everything else about Employee stays shared.
Inside an override, parent::methodName() calls the parent class's version of that method explicitly, letting a subclass *extend* rather than fully *replace* the base behavior — a very common pattern: run the parent's logic first (or last), then add subclass-specific work on top, instead of duplicating the parent's logic from scratch.
parent::__construct(...) is the same mechanism applied to constructors: a subclass constructor typically calls parent::__construct(...) first (with whatever arguments the parent needs), then does its own additional setup.
final classes and final methods
A final class cannot be extended at all — class Config extends ImmutableSettings {} fails to compile if ImmutableSettings was declared final class ImmutableSettings. This is used when a class's behavior is complete and correct exactly as written, and allowing subclasses would risk breaking assumptions the class relies on internally (a common example: value objects and other classes designed to be immutable and self-contained).
A final *method* (covered in the previous lesson) can be declared on a non-final class, forbidding only that one method from being overridden while leaving the rest of the class open to extension. This is the more surgical tool: use it when one specific method's correctness is load-bearing for the rest of the class, even if other methods are fine to customize.
Liskov-compatible signatures: what a safe override looks like
The Liskov Substitution Principle says, informally: anywhere code expects the parent type, it should work correctly if you hand it a subclass instance instead. PHP enforces part of this mechanically through its variance rules for overriding methods: a subclass method's parameter types may only be *widened or kept the same* (contravariance) and its return type may only be *narrowed or kept the same* (covariance), compared to the parent method it overrides.
Concretely: if Shape::area(): float is the parent signature, an override may narrow the return type to a subtype (e.g., a hypothetical IntBackedShape::area(): int would be invalid because int isn't a subtype of float in this sense — but returning a more specific *object* type when the parent returned a general interface is fine, e.g. parent returns Shape, subclass override returns Circle). For parameters, an override may *not* demand a narrower/more specific type than the parent accepted — doing so would break substitutability, because calling code written against the parent type might pass an argument the override can't actually handle. PHP raises a fatal Fatal error: Declaration must be compatible at class-definition time if you violate this.
Practically, most everyday overrides simply repeat the exact same signature as the parent, which is always safe. Variance mostly matters when you deliberately want a subclass to promise something *stronger* than its parent — accepting a broader range of inputs, or returning a more specific type — and PHP's type system will stop you from accidentally doing it backwards.
extends, parent::, and overriding
protected on $name/$baseSalary lets Manager's methods read them; outside code still cannot. Omitting parent::__construct(...) here would leave $name/$baseSalary unset for Manager objects.
class Employee
{
public function __construct(
protected string $name,
protected float $baseSalary,
) {}
public function annualBonus(): float
{
return $this->baseSalary * 0.05;
}
}
class Manager extends Employee
{
public function __construct(
string $name,
float $baseSalary,
private int $teamSize,
) {
parent::__construct($name, $baseSalary); // required: parent ctor is NOT automatic
}
public function annualBonus(): float
{
$base = parent::annualBonus(); // reuse parent's calculation
return $base + ($this->teamSize * 200); // then extend it
}
}Code Examples
Employee::annualBonus() computes 5% of base salary. Manager::annualBonus() overrides it but starts by calling parent::annualBonus() to get that same 5% calculation, then adds a per-team-member bonus on top — reusing the parent's logic rather than duplicating the * 0.05 calculation. Expected result: 3000 5000 return parent::annualBonus() + ($this->teamSize * 200);
Shape::withDoubledSize() promises to return a Shape. Circle::withDoubledSize() narrows that to Circle specifically — legal covariance, since every Circle is still a Shape. Code written against the parent type (report(Shape $s)) keeps working correctly no matter which subclass is actually passed in, which is exactly what Liskov substitution guarantees. Expected result: Circle public function withDoubledSize(): Circle
class Employee
{
public function __construct(protected string $name, protected float $baseSalary) {}
public function annualBonus(): float
{
return $this->baseSalary * 0.05;
}
}
class Manager extends Employee
{
public function __construct(string $name, float $baseSalary, private int $teamSize)
{
parent::__construct($name, $baseSalary);
}
public function annualBonus(): float
{
return parent::annualBonus() + ($this->teamSize * 200);
}
}
$e = new Employee('Sam', 60000);
$m = new Manager('Rae', 80000, 5);
echo $e->annualBonus(), PHP_EOL;
echo $m->annualBonus(), PHP_EOL;abstract class Shape
{
abstract public function withDoubledSize(): Shape;
}
class Circle extends Shape
{
public function __construct(private float $radius) {}
// Covariant return: narrows Shape -> Circle. Legal because Circle IS-A Shape.
public function withDoubledSize(): Circle
{
return new Circle($this->radius * 2);
}
public function radius(): float
{
return $this->radius;
}
}
function report(Shape $s): void
{
// Works for ANY Shape subclass, including Circle -- this is Liskov substitution in practice.
$bigger = $s->withDoubledSize();
echo get_class($bigger), PHP_EOL;
}
report(new Circle(3));Inheritance chain and visibility reach
protected reaches down the inheritance chain; private stops at the declaring class; parent:: reaches back up it.
class Employee outside code
+----------------------+ (script, other classes)
| protected $name | <--x cannot read/write directly
| protected $baseSalary| <--x cannot read/write directly
| public annualBonus() | <--- CAN call
+----------------------+
^ extends
|
class Manager
+----------------------+
| (inherits $name, | <--- CAN read/write ($name is protected)
| $baseSalary) |
| private $teamSize | <--x not even Employee can see this
| public annualBonus() | (Manager's own, overridden)
| -> parent::...() | ------> calls Employee::annualBonus()
+----------------------+Important Rules
Encapsulate state that has invariants to protect; leave simple, rule-free data public if that's genuinely simpler.
Use protected, not private, for state or behavior a base class expects subclasses to build on directly.
A subclass constructor must call parent::__construct(...) explicitly — it is never automatic.
When overriding, call parent::method() when you want to extend the parent's behavior instead of fully replacing it.
Keep overridden parameter types the same or wider, and return types the same or narrower, than the parent's — this is what PHP's own variance rules require.
Mark a class or method final when allowing it to be overridden would risk breaking an invariant the rest of the class depends on.
Why this matters in real codebases
Framework base classes lean on exactly this pattern: an ORM's base Model class encapsulates connection and persistence details behind protected internals, and every model you write (User extends Model, Order extends Model) inherits that machinery while overriding just the parts specific to that table. When an override accidentally breaks Liskov substitution — say, a subclass method throws on inputs the parent happily accepted — code elsewhere that was written against the parent type starts failing in ways that are hard to trace back to the subclass. Respecting variance rules and using parent:: deliberately (rather than duplicating logic) is what keeps large inheritance hierarchies maintainable instead of fragile.
class Manager extends Employee { public function __construct(string $name, float $baseSalary, private int $teamSize) { // parent::__construct() never called } } class Manager extends Employee { public function __construct(string $name, float $baseSalary, private int $teamSize) { parent::__construct($name, $baseSalary); } }
Forgetting parent::__construct(...) means $name and $baseSalary (declared and assigned only inside Employee's constructor) are never actually set for Manager objects — reading them later throws for typed properties, or silently uses a stale default.
class Employee { public function raise(float $pct): void {} } class Intern extends Employee { public function raise(int $pct): void {} } // narrower param type class Employee { public function raise(float $pct): void {} } class Intern extends Employee { public function raise(float $pct): void {} } // same or wider
Narrowing a parameter type in an override (float -> int) violates Liskov substitutability: code calling raise(2.5) against an Employee reference would break if the actual object were an Intern that only accepts int. PHP rejects this at class-definition time with a fatal 'Declaration must be compatible' error.
[multiple-choice] Why does encapsulation typically make properties private/protected rather than public? A. Because public properties are slower to access at runtime B. Because it lets the class enforce invariants by controlling how its state is read and changed C. Because PHP requires all properties to be non-public D. Because private properties use less memory than public ones
B. Because it lets the class enforce invariants by controlling how its state is read and changed — Hiding state behind methods is what lets a class guarantee rules about itself (like 'balance never goes negative') — a guarantee that's impossible to enforce if any outside code can assign directly to a public property.
[multiple-choice] A subclass constructor needs data set up by the parent's constructor. What must it do? A. Nothing — PHP calls the parent constructor automatically before the subclass constructor body runs B. Explicitly call parent::__construct(...) with the arguments the parent needs C. Redeclare all of the parent's properties again in the subclass D. Mark the subclass constructor as static
B. Explicitly call parent::__construct(...) with the arguments the parent needs — PHP never calls a parent constructor automatically when a subclass defines its own __construct(). The subclass must call parent::__construct(...) itself, typically as the first line, to run the parent's setup logic.
[predict-output] class A { public function hello(): string { return 'A'; } } class B extends A { public function hello(): string { return parent::hello() . 'B'; } } class C extends B { public function hello(): string { return parent::hello() . 'C'; } } echo (new C())->hello(); A. C B. CBA C. ABC D. A
C. ABC — C::hello() calls parent::hello() (B's version) first, then appends 'C'. B::hello() calls parent::hello() (A's version) first, then appends 'B'. A::hello() just returns 'A'. Building outward: 'A' -> 'A'.'B' = 'AB' -> 'AB'.'C' = 'ABC'.
Write a base class Vehicle with a protected int $speed = 0 and a public method describe(): string returning "Speed: {$this->speed}". Write a subclass SportsCar extends Vehicle that overrides describe() to call parent::describe() and append " (sports car)" to the result. Make Vehicle non-final but make describe()'s override-compatible: keep the same signature.
class Vehicle { protected int $speed = 0; public function describe(): string { return "Speed: {$this->speed}"; } } class SportsCar extends Vehicle { public function describe(): string { return parent::describe() . ' (sports car)'; } }
Call parent::describe() inside the override and concatenate the extra text onto its return value; don't recompute the speed string from scratch.