Objects and the Type System in PHP
AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.
Since PHP 8, type declarations, constructor promotion, enums and readonly properties are all part of the language. Before reaching for design patterns, make the types strict — most of the design work follows from that.
Before you start
The examples are verified on PHP 8.4. Put declare(strict_types=1); at the top of every file: type mismatches then throw TypeError instead of being silently coerced.
Classes and constructor promotion
<?php
declare(strict_types=1);
final class Post
{
public function __construct(
public readonly int $id,
public string $title,
private string $body = '',
) {}
public function excerpt(int $length = 20): string
{
return mb_strimwidth($this->body, 0, $length, '…');
}
}
$post = new Post(1, 'Title', 'A fairly long body...');
public readonly int $id declares the property, the parameter and the assignment in one line. A readonly property can be written once from inside the class — normally the constructor — and any later write is an Error. That is exactly the semantics of an immutable identifier.
final class forbids inheritance. Apply it by default and drop it only where extension is genuinely part of the design.
Type declarations
function sum(int|float ...$nums): int|float
{
return array_sum($nums);
}
function findUser(int $id): ?User // may return null
{
return null;
}
function logLine(string $msg): void {} // returns nothing
function fail(): never // always throws or exits
{
throw new RuntimeException('fail');
}
?TisT|null. Do not mix it withT|false— pick one failure representation per API.- A
= nulldefault used to make a parameter implicitly nullable. Write?Texplicitly; PHP 8.4 deprecates relying on the implicit form. mixedaccepts anything and gives up checking. Reserve it for boundaries such asjson_decoderesults.
Element types of arrays cannot be expressed in a signature. Use a docblock or a value object:
/** @param list<Post> $posts */
function renderList(array $posts): string { /* ... */ }
Static analysers read those docblocks, so they are not just comments.
Enums
Stop passing status strings around:
enum PostStatus: string
{
case Draft = 'draft';
case Published = 'published';
case Archived = 'archived';
public function label(): string
{
return match ($this) {
self::Draft => 'Draft',
self::Published => 'Published',
self::Archived => 'Archived',
};
}
public static function fromInput(string $value): self
{
return self::tryFrom($value) ?? throw new InvalidArgumentException("Unknown status: {$value}");
}
}
$status = PostStatus::Published;
echo $status->value; // published
echo $status->label(); // Published
from() throws ValueError on failure; tryFrom() returns null. For request input, tryFrom plus your own exception message produces a far better error than a stack trace from the enum internals.
Backed enums also serialise cleanly: json_encode($status) yields "published", which is why they pair well with database columns.
Interfaces and abstract classes
An interface declares capability; an abstract class can also supply implementation:
interface Repository
{
public function find(int $id): ?Post;
/** @return list<Post> */
public function all(): array;
}
abstract class BaseRepository implements Repository
{
public function __construct(protected PDO $pdo) {}
public function find(int $id): ?Post
{
$stmt = $this->pdo->prepare('SELECT * FROM posts WHERE id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
return $row ? new Post((int) $row['id'], $row['title'], $row['body']) : null;
}
abstract public function all(): array;
}
final class PdoPostRepository extends BaseRepository
{
public function all(): array
{
return $this->pdo->query('SELECT * FROM posts ORDER BY id DESC')->fetchAll();
}
}
The decision rule: share implementation with an abstract class, share a contract with an interface. PHP allows single inheritance but many interfaces, so anything that needs to be swappable — repositories, mailers, clocks, HTTP clients — belongs behind an interface.
Traits: horizontal reuse
trait HasTimestamps
{
public function touch(): void
{
$this->updatedAt = new DateTimeImmutable();
}
}
final class Comment
{
use HasTimestamps;
public DateTimeImmutable $updatedAt;
}
A trait copies its methods into the class. That suits cross-cutting details like timestamps or logging that have nothing to do with the inheritance tree. What it does not do is declare dependencies: if the trait touches $this->updatedAt, the using class must define it, and nothing checks that before runtime.
Static members and constants
final class Config
{
public const DEFAULT_PAGE_SIZE = 20;
private static ?self $instance = null;
public static function get(): self
{
return self::$instance ??= new self();
}
}
self:: refers to the class where the code is written; static:: refers to the runtime class (late static binding). A static return type has been available since PHP 8.0 and makes fluent interfaces type-safe.
Typed constants arrived in PHP 8.3 — public const int DEFAULT_PAGE_SIZE = 20; — and catch accidental type drift at compile time.
Inheritance or composition
// inheritance: is-a, and the parent implementation is genuinely reusable
final class AdminUser extends User {}
// composition: has-a, behaviour injected as dependencies
final class PostPublisher
{
public function __construct(
private Repository $posts,
private Mailer $mailer,
) {}
public function publish(int $id): void
{
$post = $this->posts->find($id) ?? throw new RuntimeException('Not found');
// ...
}
}
Inheritance chains three levels deep become hard to follow, and the parent’s protected surface becomes an accidental API. Extract the varying part behind an interface and inject it — tests get easier for the same reason.
Common mistakes
1. Forgetting strict_types
function add(int $a) { return $a; }
add("3abc"); // coerced to 3 in weak mode, warning swallowed
With declare(strict_types=1); this throws TypeError and the bug surfaces during development instead of in production.
2. Trying to modify a readonly property
Build a new object rather than mutating:
$updated = new Post($post->id, 'New title', $post->body);
3. Heavy work in a constructor
Queries and HTTP calls make objects impossible to instantiate in tests. Constructors assign and validate invariants, nothing else.
4. Arrays as data transfer objects
['title' => ..., 'status' => ...] silently accepts a typo in a key. Past three fields, define a value object or a readonly class.
5. Comparing an enum to a string
$status === 'published' is always false once the property is typed as the enum. Use PostStatus::Published.
6. Touching undeclared properties in a trait
It fails at runtime. Declare the dependency as an abstract method or an interface so static analysis can see it.
7. Overusing __get / __call
IDEs cannot autocomplete them and analysers give up. If dynamic behaviour is genuinely required, add @property / @method docblocks.
Summary
- Constructor promotion plus
readonlymakes value objects short and immutable. - Enums replace status strings;
tryFromhandles untrusted input. - Interfaces define contracts, abstract classes share implementation, traits carry cross-cutting details.
- Default to
final, and prefer composition over deep inheritance. strict_types=1is the precondition for every other type declaration to mean anything.
Next: separating queries from output with a render function, layouts and escaped templates.