Functions, classes and namespaces
Interfaces, Traits and Visibility
Pick visibility deliberately, choose between abstract classes and interfaces, resolve trait conflicts, and understand what static versus self does at runtime.
A class marked final with a private constructor and no extension points is
easy to change, because nothing outside it can depend on its internals. The same class with
protected methods and static return types is a different
proposition: anyone can extend it, and a change to the constructor reaches every
subclass.
Visibility, abstract classes, interfaces and traits are the tools that decide how much
of that contract you are signing. They are usually chosen by habit: public because it
works, protected because a subclass needed it, a trait because two classes shared a method.
Each habit has a cost that shows up about a year later. This lesson is about choosing on
purpose, and about the two keywords, self and static, that decide
which class your code is really talking to.
Examples run on PHP 8.5.6; every construct here works from PHP 8.3.
protected is the visibility that creates coupling
Public and private are easy to reason about. Public is the contract you offer the world. Private is the part you keep to yourself. Protected sits between them and is the reason people argue about inheritance: a protected member is visible to subclasses, which means it is part of your API without being part of your documentation.
<?php
declare(strict_types=1);
class Account
{
public string $owner;
protected int $balance;
public function __construct(string $owner, int $balance)
{
$this->owner = $owner;
$this->balance = $balance;
}
protected function debit(int $cents): void
{
$this->balance -= $cents;
}
}
final class SavingsAccount extends Account
{
public function withdraw(int $cents): void
{
$this->debit($cents);
}
public function balanceInCents(): int
{
return $this->balance;
}
}
$account = new SavingsAccount('Ana', 500_00);
$account->withdraw(120_00);
echo $account->owner, ' ', $account->balanceInCents(), PHP_EOL;
foreach (
[
'property' => static fn () => $account->balance,
'method' => static fn () => $account->debit(1),
] as $label => $case
) {
try {
$case();
} catch (Error $e) {
echo $label, ': ', $e->getMessage(), PHP_EOL;
}
}
Ana 38000
property: Cannot access protected property SavingsAccount::$balance
method: Call to protected method Account::debit() from global scope
From outside, protected behaves like private: the class refuses the access and the error
names the scope it refused from. From inside a subclass, it behaves like public. That split
is what makes it expensive. The day you decide Account should not expose a
debit() method to any subclass, you cannot tighten it to private without
breaking classes that may live in another package you do not control.
Private has a different behaviour that surprises people the first time. A subclass does not inherit private members, and a private property declared in a parent is invisible in the child, even when the child is an instance of the parent:
<?php
declare(strict_types=1);
class Account
{
private string $pin = '1234';
public function pinMatches(string $candidate): bool
{
return hash_equals($this->pin, $candidate);
}
}
final class SavingsAccount extends Account
{
public function readPin(): mixed
{
return $this->pin;
}
}
var_dump((new SavingsAccount())->pinMatches('1234'));
var_dump((new SavingsAccount())->readPin());
bool(true)
PHP Warning: Undefined property: SavingsAccount::$pin in ... on line 18
NULL
The public method still reaches the parent's private property, because it runs in the
scope where that property was declared. The subclass method does not; it sees an undefined
property and returns null behind a typed return, which is how a private member
becomes a runtime warning instead of a compile error. One useful exception exists: two
instances of the same class can read each other's private members, so
equals(self $other) comparing $other->pin is legal inside the
class that declares it.
The rule I use: private by default, public when something outside genuinely needs it, and protected only when the class is designed for subclassing and the method is meant to be overridden. If you cannot name the subclass that needs a protected member, it is private.
Abstract classes share code, interfaces promise behaviour
Both are declared, neither can be instantiated directly, and both fail the same way when you try:
Error: Cannot instantiate abstract class BaseExporter
Error: Cannot instantiate interface Exporter
What differs is what they can contain and what they mean to a caller. An abstract class is a partially built object: it can hold state, define a constructor, implement some methods, and leave others abstract. An interface is a list of method signatures with no implementation and no state, and a class may implement as many of them as it likes while extending only one parent.
<?php
declare(strict_types=1);
interface Exporter
{
public function export(array $rows): string;
}
abstract class BaseExporter implements Exporter
{
public function __construct(protected string $separator = ',')
{
}
public function export(array $rows): string
{
return implode("\n", array_map($this->line(...), $rows));
}
abstract protected function line(array $row): string;
}
final class CsvExporter extends BaseExporter
{
protected function line(array $row): string
{
return implode($this->separator, $row);
}
}
$exporter = new CsvExporter(';');
echo $exporter->export([['INV-1', 'paid'], ['INV-2', 'open']]), PHP_EOL;
INV-1;paid
INV-2;open
BaseExporter earns its abstract status by owning the loop and the separator,
which every format needs, and by pushing the one line that differs down to the subclass.
Exporter earns its place by describing what callers require without saying
anything about the loop, which is what lets a future exporter implement the interface
without inheriting the base class at all.
The failure mode on the other side is worth seeing, because it is a compile-time error rather than a test failure. Leave the abstract method unimplemented and PHP refuses to load the class:
PHP Fatal error: Class Incomplete contains 1 abstract method and must therefore be declared abstract or implement the remaining method (Base::line) in ... on line 9
Choose the interface when the only thing you can say about the implementation is what callers may do with it. Choose the abstract class when two or more implementations would otherwise copy the same body, or when the contract includes a constructor. If you are unsure, start with the interface; converting it to an abstract class later is a smaller change than removing inherited state from subclasses.
A fat interface forces every implementer to lie
The failure mode that interface segregation fixes is easy to recognise once you see it: a
class that implements a method by throwing or by returning an empty array, because the
interface required a method it cannot honestly provide. A repository interface with
find(), save(), delete() and all()
forces a read-only source to implement three methods it will never support. Split it by what
callers actually do, and each class implements only what it can keep:
<?php
declare(strict_types=1);
interface ReadsRows
{
public function fetch(string $id): ?array;
}
interface WritesRows
{
public function save(array $row): void;
}
interface ListsRows
{
public function all(): array;
}
final class InvoiceRepository implements ReadsRows, WritesRows, ListsRows
{
private array $rows = [];
public function fetch(string $id): ?array
{
return $this->rows[$id] ?? null;
}
public function save(array $row): void
{
$this->rows[$row['id']] = $row;
}
public function all(): array
{
return array_values($this->rows);
}
}
final class InvoiceReport
{
public function __construct(private ListsRows $rows)
{
}
public function totalCents(): int
{
return array_sum(array_column($this->rows->all(), 'cents'));
}
}
$repository = new InvoiceRepository();
$repository->save(['id' => 'INV-1', 'cents' => 129900]);
$repository->save(['id' => 'INV-2', 'cents' => 5000]);
echo (new InvoiceReport($repository))->totalCents(), PHP_EOL;
134900
A class may still implement all three, as this repository does, and callers that only
read see only the one they use. InvoiceReport cannot call
save() on its dependency even by accident. That is the whole benefit, and it
is a benefit at the type level rather than at runtime: nothing in the class body prevents a
bad call, the constructor signature does.
Traits copy methods in, and conflicts are resolved explicitly
A trait is a bag of methods and properties pasted into a class at compile time. It is not
a type, so nothing can be type declared against it, and it takes part in
instanceof checks not at all. What it does well is share one behaviour between
classes that share no ancestor.
Two traits cannot both define the same method without a ruling. PHP refuses to guess, and
the resolution syntax names the winner with insteadof and can alias the loser
with as:
<?php
declare(strict_types=1);
trait HasTimestamps
{
public function createdAt(): string
{
return '2026-10-16T09:00:00Z';
}
public function describe(): string
{
return 'timestamps';
}
}
trait HasSoftDeletes
{
public function describe(): string
{
return 'soft deletes';
}
private function purge(): string
{
return 'purged';
}
}
final class Invoice
{
use HasTimestamps, HasSoftDeletes {
HasTimestamps::describe insteadof HasSoftDeletes;
HasSoftDeletes::describe as deletedDescription;
HasSoftDeletes::purge as public purgeNow;
}
}
$invoice = new Invoice();
echo $invoice->createdAt(), PHP_EOL;
echo $invoice->describe(), PHP_EOL;
echo $invoice->deletedDescription(), PHP_EOL;
echo $invoice->purgeNow(), PHP_EOL;
var_dump(method_exists($invoice, 'purge'));
2026-10-16T09:00:00Z
timestamps
soft deletes
purged
bool(true)
Three things happen in that block: insteadof chooses an implementation,
as keeps the other one under a new name, and as public changes
visibility. Here HasTimestamps::describe wins, the discarded
HasSoftDeletes::describe stays reachable as
deletedDescription(), and purge(), private in the trait, gains a
public alias named purgeNow() while the original name stays private.
method_exists() returns true for the private original because the
method exists; calling $invoice->purge() would still fail.
Two properties of traits matter when you design with them. A method conflict is resolved
by the class, so adding a second trait can require editing that class. And a trait cannot
implement an interface for you: the class that uses it must still declare
implements, even when the trait supplies every method. Prefer a trait for a
self-contained behaviour with no dependencies on the class's other state, and prefer an
interface plus a small class when callers need a type to point at. A trait is not a type, and
nothing can be type declared against it.
self is where the code lives; static is who was called
Inside a class, self resolves at compile time to the class that contains the
code. static resolves at runtime to the class that was actually called, which
is late static binding. In a base class the two usually disagree, and the disagreement is
the difference between a factory that returns the wrong class and one that returns the
right one.
<?php
declare(strict_types=1);
abstract class Query
{
public function where(string $column): static
{
echo 'where ', $column, ' on ', static::class, PHP_EOL;
return $this;
}
public static function table(): string
{
return 'records';
}
public static function describeSelf(): string
{
return 'self::table() -> ' . self::table();
}
public static function describeStatic(): string
{
return 'static::table() -> ' . static::table();
}
public static function makeWithStatic(): static
{
return new static();
}
}
final class InvoiceQuery extends Query
{
public static function table(): string
{
return 'invoices';
}
}
echo InvoiceQuery::describeSelf(), PHP_EOL;
echo InvoiceQuery::describeStatic(), PHP_EOL;
$query = (new InvoiceQuery())->where('status');
var_dump($query instanceof InvoiceQuery);
$copy = InvoiceQuery::makeWithStatic();
var_dump($copy instanceof InvoiceQuery);
self::table() -> records
static::table() -> invoices
where status on InvoiceQuery
bool(true)
bool(true)
self::table() looked up the method on Query and returned the
base value. static::table() reached the override in
InvoiceQuery. The same distinction applies to new: inside an
abstract class, new self() is an error, while
new static() returns the called subclass, which is why the
static return type and the new static() body belong together.
Error: Cannot instantiate abstract class Query
Rule of thumb: use static for fluent methods, factories and anything whose
answer depends on the subclass being used, and use self when you deliberately
want the implementation in the class where the code is written. A concrete example of the
deliberate case is a base class that wants a fixed helper regardless of overrides, and
self is how you say that on purpose rather than by omission.
Promotion and readonly remove boilerplate and close a hole
Constructor property promotion declares the property, the parameter and the assignment in one line. The visibility keyword before the parameter is what makes it promoted, and the property behaves exactly as a separately declared one.
<?php
declare(strict_types=1);
final class Money
{
public function __construct(
public readonly int $cents,
public readonly string $currency = 'EUR',
) {
if ($cents < 0) {
throw new InvalidArgumentException('Amount cannot be negative');
}
}
public function format(): string
{
return number_format($this->cents / 100, 2, '.', '') . ' ' . $this->currency;
}
}
echo (new Money(129900))->format(), PHP_EOL;
echo (new Money(cents: 5000, currency: 'USD'))->format(), PHP_EOL;
1299.00 EUR
50.00 USD
Promotion is not a replacement for a body, as the guard clause shows. Validation still happens in the constructor, after assignment, and throwing from there is the normal way to reject an invalid value. What promotion removes is the three lines of ceremony per property that people get wrong when they add a fourth field and forget the assignment.
readonly is the part that changes the design. A readonly property may be
written once from the scope that declares it, and attempting a second write fails at
runtime with Error: Cannot modify readonly property Money::$cents. Combined
with promotion it gives you a value object whose fields cannot drift, and a
final class means nobody can add a setter in a subclass:
<?php
declare(strict_types=1);
final readonly class Percentage
{
public function __construct(public int $basisPoints)
{
if ($basisPoints < 0 || $basisPoints > 10_000) {
throw new InvalidArgumentException('Outside 0-100%');
}
}
public function of(int $cents): int
{
return intdiv($cents * $this->basisPoints, 10_000);
}
public function bump(int $basisPoints): self
{
return new self($this->basisPoints + $basisPoints);
}
}
echo (new Percentage(2100))->of(129900), PHP_EOL;
echo (new Percentage(2100))->bump(100)->basisPoints, PHP_EOL;
27279
2200
A readonly class, available since PHP 8.2, marks every property readonly and forbids
static properties, which is a fair summary of what a value object is. Changes produce new
instances, as bump() does. Use these on things that are values, such as money,
identifiers, date ranges and configuration read from the environment. Do not use them on
entities that change identity over time; a readonly customer is a customer you cannot
update.
Build a small notifier with the four mechanisms in this lesson.
Declare interfaces Notifies with send(string $to): void and
LogsAttempts with attempts(): int. Write an abstract class
BaseNotifier that implements Notifies, holds a protected
attempt counter, and provides a concrete send() that validates the address
by calling an abstract isValidAddress() before calling an abstract
deliver(). Then write EmailNotifier and
SmsNotifier as subclasses.
Add two traits: HasTimestamps with a describe(): string and
a createdAt(): string, and HasRetries that also defines
describe(): string. Use both traits in EmailNotifier, keep the
timestamp description under the name describe(), and expose the retry
description as retryDescription().
Finally, call send() on each notifier with a valid address and then with
an invalid one, and have BaseNotifier count the rejected attempt and skip
delivery. Report attempts() for both objects at the end.
Show the solution and the reasoning
<?php
declare(strict_types=1);
interface Notifies
{
public function send(string $to): void;
}
interface LogsAttempts
{
public function attempts(): int;
}
trait HasTimestamps
{
public function createdAt(): string
{
return '2026-10-16T09:00:00Z';
}
public function describe(): string
{
return 'timestamps';
}
}
trait HasRetries
{
public function describe(): string
{
return 'retries';
}
}
abstract class BaseNotifier implements Notifies, LogsAttempts
{
protected int $attempts = 0;
public function send(string $to): void
{
$this->attempts++;
if (!$this->isValidAddress($to)) {
return;
}
$this->deliver($to);
}
public function attempts(): int
{
return $this->attempts;
}
abstract protected function isValidAddress(string $to): bool;
abstract protected function deliver(string $to): void;
}
final class EmailNotifier extends BaseNotifier
{
use HasTimestamps, HasRetries {
HasTimestamps::describe insteadof HasRetries;
HasRetries::describe as retryDescription;
}
protected function isValidAddress(string $to): bool
{
return filter_var($to, FILTER_VALIDATE_EMAIL) !== false;
}
protected function deliver(string $to): void
{
echo 'email to ', $to, PHP_EOL;
}
}
final class SmsNotifier extends BaseNotifier
{
protected function isValidAddress(string $to): bool
{
return preg_match('/^\+[0-9]{9,15}$/', $to) === 1;
}
protected function deliver(string $to): void
{
echo 'sms to ', $to, PHP_EOL;
}
}
$email = new EmailNotifier();
$email->send('ana@example.com');
$email->send('not-an-address');
$email->send('devon@example.com');
$sms = new SmsNotifier();
$sms->send('+34600111222');
$sms->send('600111222');
echo $email->describe(), ' / ', $email->retryDescription(), PHP_EOL;
echo 'email attempts: ', $email->attempts(), PHP_EOL;
echo 'sms attempts: ', $sms->attempts(), PHP_EOL;
email to ana@example.com
email to devon@example.com
sms to +34600111222
timestamps / retries
email attempts: 3
sms attempts: 2
The validation failure counts as an attempt because the counter increments before the check. That ordering is a decision, not an accident: you usually want to rate limit attempts, including the ones that were rejected, and counting only successful sends leaves the obvious abuse path open. If you wanted the opposite, increment after the check and say so in the method name, because the difference is invisible from the call site.
The abstract class carries the parts that are identical for every channel: the counter,
the sequence of validate then deliver, and the attempts() accessor. The
subclasses supply the two decisions that differ, which is the smallest surface a subclass
can be asked to fill. The visibility is deliberate: attempts() is public
because callers ask for it, isValidAddress() and deliver() are
protected because only the base class calls them, and the counter is protected so a
subclass may read it. The trait block is the only fiddly part.
HasTimestamps::describe insteadof HasRetries resolves the collision in
favour of the timestamp trait, and the as line keeps the retry version
reachable under a second name. Reversing the choice is one word. What you cannot do is
leave both methods named describe() and hope; PHP reports a fatal error about
a trait method collision before the class loads. That is the trade trait users accept:
pasting is cheap, and collisions have to be adjudicated by hand.
Progress
Mark this lesson done and it stays marked in this browser.