Classes, Interfaces and Enums
Modern PHP's object model is conventional and well-typed. The parts worth learning properly are the constructor shorthand, what readonly actually guarantees, and enums - which replaced a decade of class constants and got it right.
A class, written the modern way
<?php
declare(strict_types=1);
final class Lesson
{
// Constructor property promotion: declare, type and assign in one
// place. This replaces three lines per property.
public function __construct(
public readonly int $id,
public readonly string $title,
public readonly int $minutes,
private array $tags = [],
) {
if ($minutes < 0) {
throw new InvalidArgumentException('minutes must not be negative');
}
}
public function tags(): array
{
return $this->tags; // arrays copy, so this is safe
}
public function withTag(string $tag): self
{
$clone = clone $this;
$clone->tags[] = $tag; // allowed: tags is not readonly
return $clone;
}
public function __toString(): string
{
return sprintf('%s (%d min)', $this->title, $this->minutes);
}
}
$lesson = new Lesson(1, 'Selectors', 7, ['css']);
echo $lesson, PHP_EOL; // Selectors (7 min)
echo $lesson->id, PHP_EOL; // 1
print_r($lesson->withTag('cascade')->tags()); // ['css', 'cascade']
print_r($lesson->tags()); // ['css'] - unchanged
try {
$lesson->id = 2;
} catch (Error $e) {
echo get_class($e), PHP_EOL; // Error
}
Open this example in the editor and change it. Nothing you type is saved.
final by default is worth the habit. A class that is not designed for inheritance and is not final is an accident waiting for a subclass that overrides half of it.
readonly means assignable once, from inside the declaring class. It is enforced at run time, unlike TypeScript's compile-time version - a write from outside is a fatal Error, not a warning. PHP 8.2 added readonly class, which marks every property at once.
Visibility, statics and constants
<?php
declare(strict_types=1);
final class Counter
{
public const int MAX = 100; // typed constants, PHP 8.3
private const string PREFIX = 'c-';
private static int $instances = 0;
private int $value = 0;
public function __construct(private readonly string $name)
{
self::$instances++;
}
public function increment(int $by = 1): static
{
if ($this->value + $by > self::MAX) {
throw new OverflowException('would exceed ' . self::MAX);
}
$this->value += $by;
return $this; // fluent
}
public function label(): string
{
return self::PREFIX . $this->name . '=' . $this->value;
}
public static function created(): int
{
return self::$instances;
}
}
$a = (new Counter('a'))->increment()->increment(5);
$b = new Counter('b');
echo $a->label(), PHP_EOL; // c-a=6
echo Counter::created(), PHP_EOL; // 2
echo Counter::MAX, PHP_EOL; // 100
try {
$b->increment(999);
} catch (OverflowException $e) {
echo $e->getMessage(), PHP_EOL;
}
Open this example in the editor and change it. Nothing you type is saved.
self refers to the class where the code is written; static refers to the class that was actually called, which matters for inheritance:
<?php
declare(strict_types=1);
class Base
{
public static function createSelf(): self { return new self(); }
public static function createStatic(): static { return new static(); }
}
final class Derived extends Base {}
echo get_class(Derived::createSelf()), PHP_EOL; // Base - probably wrong
echo get_class(Derived::createStatic()), PHP_EOL; // Derived
Open this example in the editor and change it. Nothing you type is saved.
For a factory method, static is almost always what you meant.
Interfaces and abstract classes
<?php
declare(strict_types=1);
interface Renderable
{
public function render(): string;
}
interface Cacheable
{
public function cacheKey(): string;
public function ttlSeconds(): int;
}
abstract class Block implements Renderable
{
public function __construct(protected readonly string $title) {}
// Concrete: shared by every subclass.
public function heading(): string
{
return '<h2>' . htmlspecialchars($this->title, ENT_QUOTES) . '</h2>';
}
// Abstract: each subclass must supply it.
abstract protected function body(): string;
final public function render(): string
{
return $this->heading() . $this->body();
}
}
final class TextBlock extends Block implements Cacheable
{
public function __construct(string $title, private readonly string $text) {
parent::__construct($title);
}
protected function body(): string
{
return '<p>' . htmlspecialchars($this->text, ENT_QUOTES) . '</p>';
}
public function cacheKey(): string { return 'text-' . md5($this->text); }
public function ttlSeconds(): int { return 3600; }
}
$block = new TextBlock('Hello', 'A <b>quoted</b> sentence');
echo $block->render(), PHP_EOL;
var_dump($block instanceof Renderable, $block instanceof Cacheable);
echo substr($block->cacheKey(), 0, 9), PHP_EOL;
Open this example in the editor and change it. Nothing you type is saved.
The division: an interface is a contract with no implementation and a class may implement many. An abstract class is a partial implementation and a class may extend only one.
Prefer interfaces for the contract and composition for the sharing. The render() marked final above is the template-method pattern: the shape is fixed, only the hole is pluggable - which is what makes it safe for a subclass to exist at all.
Enums
Enums replaced the old const approach entirely, and they carry behaviour.
<?php
declare(strict_types=1);
enum Status: string
{
case Draft = 'draft';
case Published = 'published';
case Archived = 'archived';
// Enums can have methods, and $this is the case.
public function label(): string
{
return match ($this) {
Status::Draft => 'Not visible yet',
Status::Published => 'Live',
Status::Archived => 'Retired',
};
}
public function isVisible(): bool
{
return $this === Status::Published;
}
// And static methods, including alternative constructors.
public static function fromLabel(string $label): self
{
foreach (self::cases() as $case) {
if ($case->label() === $label) {
return $case;
}
}
throw new ValueError('no status with label ' . $label);
}
}
$status = Status::Published;
echo $status->value, ' ', $status->name, ' ', $status->label(), PHP_EOL;
// published Published Live
var_dump($status->isVisible());
var_dump($status === Status::Published); // identity works
// from() throws, tryFrom() returns null. Use tryFrom at a boundary.
var_dump(Status::tryFrom('draft'));
var_dump(Status::tryFrom('nonsense')); // NULL
try {
Status::from('nonsense');
} catch (ValueError $e) {
echo get_class($e), PHP_EOL; // ValueError
}
echo implode(', ', array_column(Status::cases(), 'value')), PHP_EOL;
echo Status::fromLabel('Retired')->value, PHP_EOL; // archived
Open this example in the editor and change it. Nothing you type is saved.
Three things an enum gives you that a class constant did not: a type you can put in a signature, so function publish(Status $s) cannot be passed a typo; exhaustive match, which throws UnhandledMatchError when a case is added and a match is not updated; and cases(), so the list and the type cannot drift.
A pure enum has no backing value, and is right when the values are never stored or transmitted:
<?php
declare(strict_types=1);
enum Direction
{
case Up;
case Down;
public function opposite(): self
{
return match ($this) {
Direction::Up => Direction::Down,
Direction::Down => Direction::Up,
};
}
}
enum Status: string
{
case Draft = 'draft';
}
echo Direction::Up->opposite()->name, PHP_EOL; // Down
var_dump(Direction::Up instanceof UnitEnum); // true - no backing value
var_dump(Status::Draft instanceof BackedEnum); // true - it has one
var_dump(Status::Draft instanceof UnitEnum); // true - BackedEnum extends it
Open this example in the editor and change it. Nothing you type is saved.
Enums can implement interfaces and hold constants, but they cannot have state - there is exactly one instance of each case, for the lifetime of the request, which is what makes === the right comparison.
A worked example
<?php
declare(strict_types=1);
enum Level: int
{
case Debug = 10;
case Info = 20;
case Warning = 30;
case Error = 40;
public function label(): string
{
return strtoupper($this->name);
}
public function atLeast(self $other): bool
{
return $this->value >= $other->value;
}
}
interface Handler
{
public function handle(Entry $entry): void;
}
final readonly class Entry
{
public function __construct(
public Level $level,
public string $message,
public array $context = [],
) {}
public function formatted(): string
{
$context = $this->context === []
? ''
: ' ' . implode(' ', array_map(
fn (string $k, mixed $v): string => $k . '=' . var_export($v, true),
array_keys($this->context),
array_values($this->context),
));
return sprintf('[%-7s] %s%s', $this->level->label(), $this->message, $context);
}
}
final class CollectingHandler implements Handler
{
/** @var list<string> */
private array $lines = [];
public function __construct(private readonly Level $minimum) {}
public function handle(Entry $entry): void
{
if (!$entry->level->atLeast($this->minimum)) {
return;
}
$this->lines[] = $entry->formatted();
}
public function lines(): array { return $this->lines; }
}
final class Logger
{
/** @var list<Handler> */
private array $handlers = [];
public function add(Handler $handler): static
{
$this->handlers[] = $handler;
return $this;
}
public function log(Level $level, string $message, array $context = []): void
{
$entry = new Entry($level, $message, $context);
foreach ($this->handlers as $handler) {
$handler->handle($entry);
}
}
}
$verbose = new CollectingHandler(Level::Debug);
$quiet = new CollectingHandler(Level::Warning);
$logger = (new Logger())->add($verbose)->add($quiet);
$logger->log(Level::Debug, 'cache miss', ['key' => 'lesson:7']);
$logger->log(Level::Info, 'request served', ['ms' => 31]);
$logger->log(Level::Warning, 'slow query', ['ms' => 1902]);
$logger->log(Level::Error, 'connection refused');
echo 'verbose:', PHP_EOL;
foreach ($verbose->lines() as $line) {
echo ' ', $line, PHP_EOL;
}
echo 'quiet:', PHP_EOL;
foreach ($quiet->lines() as $line) {
echo ' ', $line, PHP_EOL;
}
printf('%sverbose kept %d, quiet kept %d%s',
PHP_EOL, count($verbose->lines()), count($quiet->lines()), PHP_EOL);
// The enum is a type, so this is a TypeError rather than a typo that
// reaches production:
try {
// @phpstan-ignore-next-line - deliberate
$logger->log('warning', 'a string level');
} catch (TypeError $e) {
echo 'TypeError', PHP_EOL;
}
// And the list of levels comes from the enum rather than a constant
// somewhere else that could disagree with it.
echo implode(' < ', array_map(fn (Level $l): string => $l->label(), Level::cases())), PHP_EOL;
Open this example in the editor and change it. Nothing you type is saved.
The shape to take away is the division of labour. Level is an enum because it is a closed set with behaviour. Entry is a readonly class because it is data that must not change after construction. Handler is an interface because several unrelated classes implement it. And Logger holds handlers rather than extending anything, which is why adding a handler needs no new subclass.
When it goes wrong
| Symptom | Cause |
|---|---|
Cannot modify readonly property |
That is the point; build a new instance |
| A readonly array was mutated anyway | Arrays copy, so the caller changed their copy |
| A factory returned the base class | self instead of static |
UnhandledMatchError after adding an enum case |
A match that needs the new case - go and add it |
ValueError from Status::from() |
Use tryFrom() at a boundary |
| A subclass broke the parent's invariants | Make the class final unless designed otherwise |
| Two enum cases compared unequal | They are singletons; === is correct, check the import |
| A clone shares nested objects | clone is shallow; implement __clone |
A check you can run
<?php
enum Status: string {
case Draft = 'draft';
case Published = 'published';
}
function label(Status $s): string {
return match ($s) {
Status::Draft => 'Draft',
Status::Published => 'Live',
};
}
echo label(Status::Draft), PHP_EOL;
Open this example in the editor and change it. Nothing you type is saved.
Add case Archived = 'archived'; to the enum and call label(Status::Archived). It throws UnhandledMatchError, naming the file and line of the match that needs updating.
Now add a default => 'Unknown' arm and do it again: it compiles, it runs, and the new status silently shows as "Unknown" everywhere. The absence of a default is the feature - it is what turns "someone will remember to update the other six matches" into an error with a line number.