Object-oriented PHP

Classes and promoted constructors, visibility, static members, inheritance, interfaces, traits, readonly properties and enums.

Classes, constructors and visibility

<?php
declare(strict_types=1);

final class Invoice
{
    public function __construct(
        public readonly string $number,
        private array $lines = [],
        protected ?DateTimeImmutable $issuedAt = null,
    ) {
    }

    public function total(): float
    {
        return array_sum(array_map(fn(Line $l) => $l->total(), $this->lines));
    }

    public static function fromRow(array $row): self
    {
        return new self(number: $row["number"]);
    }
}
  • Constructor property promotion declares and assigns in one place; the properties are real properties with real types.
  • readonly means written once, in the constructor, then immutable - the cheapest way to keep a value object honest.
  • public is readable and writable by anyone, protected is open to subclasses, private stays inside the class.
  • static members belong to the class, not to an instance; inside the class use self:: for the defining class and static:: when a subclass should be able to override.
  • final on the class documents that inheritance is not part of the design, and the static analyser will warn if someone tries.

Interfaces, abstract classes, traits and enums

interface Repository
{
    public function find(int $id): ?Post;
    public function save(Post $post): void;
}

abstract class BaseRepository implements Repository
{
    abstract protected function table(): string;

    public function find(int $id): ?Post
    {
        $stmt = $this->db->prepare("SELECT * FROM " . $this->table() . " WHERE id = ?");
        $stmt->execute([$id]);
        return $stmt->fetchObject(Post::class) ?: null;
    }
}

trait LogsQueries
{
    private function logQuery(string $sql): void
    {
        $this->logger->debug($sql);
    }
}

enum Status: string
{
    case Draft = "draft";
    case Live  = "live";

    public function label(): string
    {
        return match ($this) {
            Status::Draft => "Draft",
            Status::Live  => "Live",
        };
    }
}
ToolUse it when
InterfaceSeveral unrelated classes must satisfy one contract
Abstract classSubclasses share implementation and must fill in a gap
TraitA method or two is copied into otherwise unrelated classes
EnumA fixed set of named values replaces loose strings or integers
Readonly classAn immutable value object with no setters at all
💡
Program against an interface, not a concrete class: type-hint Repository in the service and the storage can change - database, cache, fake in a test - without touching the caller.

Worked example: a value object and two implementations

final class Money
{
    private function __construct(public readonly int $cents, public readonly string $currency)
    {
    }

    public static function of(float $amount, string $currency = "EUR"): self
    {
        return new self((int) round($amount * 100), $currency);
    }

    public function add(self $other): self
    {
        if ($other->currency !== $this->currency) {
            throw new InvalidArgumentException("Currency mismatch");
        }
        return new self($this->cents + $other->cents, $this->currency);
    }
}

interface Notifier
{
    public function send(string $to, string $message): void;
}

final class MailNotifier implements Notifier
{
    public function send(string $to, string $message): void { /* smtp */ }
}

final class FakeNotifier implements Notifier
{
    public array $sent = [];
    public function send(string $to, string $message): void { $this->sent[] = [$to, $message]; }
}

The value object validates once and then cannot be wrong; the interface lets the test double replace the mail server with an array. Those two moves - immutable values, explicit contracts - carry most of the practical benefit of object orientation.

FAQ

When should I prefer composition over inheritance?
By default. Inherit only when the subclass genuinely is a more specific version of the parent; otherwise hold a collaborator in a property and delegate to it.
Can an enum have methods and interfaces?
Yes. Enum cases are objects: they can implement interfaces, carry methods, use traits, and be used in match and as typed parameters.

Functions, control flow and type declarations Modern PHP 8 features in practice

Last refreshed 2026-09-18.