Traits, Static and Composition

Article · 12:00

PHP has single inheritance, so the interesting question is how to share behaviour without it. Traits are one answer and composition is usually the better one - with what happens when two traits collide, and why static state resists testing.

This lesson has 8 runnable examples. Press Try yourself! under any of them to open it in the editor.

Traits, Statics and Composition

PHP gives you three ways to share behaviour between classes: inheritance, traits, and holding an object. Two of them cause trouble at scale and one does not. This lesson is about why.

Traits: copy and paste with a compiler

A trait is code that is copied into a class at compile time. It is not a type - you cannot type-hint against one, and instanceof does not see it.

PHP Example 1 36 lines
<?php
declare(strict_types=1);

trait Timestamps
{
    private ?DateTimeImmutable $createdAt = null;
    private ?DateTimeImmutable $updatedAt = null;

    public function touch(DateTimeImmutable $now): void
    {
        $this->createdAt ??= $now;
        $this->updatedAt = $now;
    }

    public function age(DateTimeImmutable $now): int
    {
        return $this->createdAt === null
            ? 0
            : $now->getTimestamp() - $this->createdAt->getTimestamp();
    }
}

final class Lesson
{
    use Timestamps;

    public function __construct(public readonly string $title) {}
}

$lesson = new Lesson('Selectors');
$lesson->touch(new DateTimeImmutable('@1000'));
$lesson->touch(new DateTimeImmutable('@1060'));
echo $lesson->age(new DateTimeImmutable('@1120')), PHP_EOL;     // 120

var_dump($lesson instanceof Lesson);
// var_dump($lesson instanceof Timestamps);     // Fatal: not a type

Open this example in the editor and change it. Nothing you type is saved.

That last line is the trait's defining limitation. If you want to accept "anything that can be touched" as a parameter, you need an interface as well:

PHP Example 2 34 lines
<?php
declare(strict_types=1);

interface Touchable
{
    public function touch(DateTimeImmutable $now): void;
}

trait Timestamps
{
    private ?DateTimeImmutable $updatedAt = null;

    public function touch(DateTimeImmutable $now): void
    {
        $this->updatedAt = $now;
    }

    public function updatedAt(): ?DateTimeImmutable { return $this->updatedAt; }
}

final class Lesson implements Touchable
{
    use Timestamps;                     // the trait satisfies the interface
    public function __construct(public readonly string $title) {}
}

function save(Touchable $thing, DateTimeImmutable $now): void
{
    $thing->touch($now);
}

$lesson = new Lesson('Grid');
save($lesson, new DateTimeImmutable('@2000'));
echo $lesson->updatedAt()?->getTimestamp(), PHP_EOL;            // 2000

Open this example in the editor and change it. Nothing you type is saved.

Interface for the type, trait for the implementation is the only use of traits that scales. A trait on its own is a way of sharing code with no contract, which is how a codebase ends up with forty classes that all "have timestamps" in subtly different ways.

When two traits collide

PHP Example 3 30 lines
<?php
declare(strict_types=1);

trait Loud
{
    public function speak(): string { return 'LOUD'; }
    public function volume(): int { return 11; }
}

trait Quiet
{
    public function speak(): string { return 'quiet'; }
    public function volume(): int { return 1; }
}

final class Speaker
{
    use Loud, Quiet {
        Loud::speak insteadof Quiet;        // resolve the clash
        Quiet::speak as whisper;            // and keep the other under a name
        Quiet::volume insteadof Loud;
        Loud::volume as maxVolume;
    }
}

$speaker = new Speaker();
echo $speaker->speak(), PHP_EOL;            // LOUD
echo $speaker->whisper(), PHP_EOL;          // quiet
echo $speaker->volume(), PHP_EOL;           // 1
echo $speaker->maxVolume(), PHP_EOL;        // 11

Open this example in the editor and change it. Nothing you type is saved.

Without the insteadof block, PHP refuses to compile the class - which is the right behaviour, and better than the silent last-one-wins of some languages.

But look at what that block costs: four lines of conflict resolution to use two traits. When you reach for it, the real answer is almost always two objects rather than two traits.

The precedence rules, when you need them: a method in the class itself beats a trait, and a trait beats an inherited method. So a trait can override the parent class, which is surprising the first time it happens.

PHP Example 4 26 lines
<?php
declare(strict_types=1);

class Base
{
    public function name(): string { return 'base'; }
}

trait Named
{
    public function name(): string { return 'trait'; }
}

final class Thing extends Base
{
    use Named;
}

final class Explicit extends Base
{
    use Named;
    public function name(): string { return 'class'; }
}

echo (new Thing())->name(), PHP_EOL;        // trait  - it beat the parent
echo (new Explicit())->name(), PHP_EOL;     // class  - which beats the trait

Open this example in the editor and change it. Nothing you type is saved.

Static, and why it resists testing

PHP Example 5 32 lines
<?php
declare(strict_types=1);

final class Config
{
    private static array $values = [];

    public static function set(string $key, string $value): void
    {
        self::$values[$key] = $value;
    }

    public static function get(string $key): ?string
    {
        return self::$values[$key] ?? null;
    }
}

final class Mailer
{
    public function send(string $to): string
    {
        // A hidden dependency. Nothing in the signature says this
        // class needs configuration, and nothing can give it a
        // different one.
        $from = Config::get('mail.from') ?? 'noreply@example.com';
        return "from $from to $to";
    }
}

Config::set('mail.from', 'hello@example.com');
echo (new Mailer())->send('aisha@example.com'), PHP_EOL;

Open this example in the editor and change it. Nothing you type is saved.

Three problems, and they are the same problem seen from three angles.

It is invisible. new Mailer() tells you nothing about what it needs. The dependency is discoverable only by reading the body.

It is global state. A test that sets mail.from leaks into the next test, so the suite passes in one order and fails in another. The usual fix - a reset() method called in setUp - is a fix for a problem that need not exist.

It cannot be substituted. You cannot give one Mailer a different configuration from another, which is exactly what a test, a multi-tenant application or a batch job wants.

Composition instead

PHP Example 6 36 lines
<?php
declare(strict_types=1);

interface Configuration
{
    public function get(string $key, ?string $default = null): ?string;
}

final class ArrayConfiguration implements Configuration
{
    public function __construct(private readonly array $values = []) {}

    public function get(string $key, ?string $default = null): ?string
    {
        return $this->values[$key] ?? $default;
    }
}

final class Mailer
{
    // Now the dependency is in the signature. Nobody has to read the
    // body to know what this class needs.
    public function __construct(private readonly Configuration $config) {}

    public function send(string $to): string
    {
        $from = $this->config->get('mail.from', 'noreply@example.com');
        return "from $from to $to";
    }
}

$live = new Mailer(new ArrayConfiguration(['mail.from' => 'hello@example.com']));
$test = new Mailer(new ArrayConfiguration([]));

echo $live->send('aisha@example.com'), PHP_EOL;
echo $test->send('aisha@example.com'), PHP_EOL;     // the default

Open this example in the editor and change it. Nothing you type is saved.

Two mailers, two configurations, no global state, and the test needs no reset(). The constructor got one line longer and everything else got simpler.

The static methods that remain legitimate are the ones with no state: a named constructor (Money::fromPounds), a pure function that belongs with a type (Uuid::isValid), a factory. The test is whether calling it twice with the same arguments can give different answers. If it can, it is global state wearing a method's clothes.

Composition over inheritance, concretely

PHP Example 7 46 lines
<?php
declare(strict_types=1);

interface Formatter
{
    public function format(string $message): string;
}

final class PlainFormatter implements Formatter
{
    public function format(string $message): string { return $message; }
}

final class PrefixedFormatter implements Formatter
{
    public function __construct(
        private readonly Formatter $inner,
        private readonly string $prefix,
    ) {}

    public function format(string $message): string
    {
        return '[' . $this->prefix . '] ' . $this->inner->format($message);
    }
}

final class TruncatingFormatter implements Formatter
{
    public function __construct(
        private readonly Formatter $inner,
        private readonly int $width,
    ) {}

    public function format(string $message): string
    {
        return mb_strimwidth($this->inner->format($message), 0, $this->width, '...');
    }
}

$formatter = new TruncatingFormatter(
    new PrefixedFormatter(new PlainFormatter(), 'app'),
    24,
);

echo $formatter->format('a short one'), PHP_EOL;
echo $formatter->format('a considerably longer message than that'), PHP_EOL;

Open this example in the editor and change it. Nothing you type is saved.

Three small classes, each with one job, assembled at the call site. The inheritance version of this needs TruncatingPrefixedFormatter, and then TimestampedTruncatingPrefixedFormatter, and that is the combinatorial explosion that gives inheritance its reputation.

A worked example

PHP Example 8 91 lines
<?php
declare(strict_types=1);

interface Clock
{
    public function now(): DateTimeImmutable;
}

final class FrozenClock implements Clock
{
    public function __construct(private DateTimeImmutable $at) {}

    public function now(): DateTimeImmutable { return $this->at; }

    public function advance(string $interval): void
    {
        $this->at = $this->at->modify($interval);
    }
}

interface RateLimiter
{
    public function allow(string $key): bool;
}

final class WindowRateLimiter implements RateLimiter
{
    /** @var array<string, list<int>> */
    private array $hits = [];

    public function __construct(
        private readonly Clock $clock,       // injected, not static
        private readonly int $max = 3,
        private readonly int $windowSeconds = 60,
    ) {}

    public function allow(string $key): bool
    {
        $now = $this->clock->now()->getTimestamp();
        $recent = array_values(array_filter(
            $this->hits[$key] ?? [],
            fn (int $at): bool => $now - $at < $this->windowSeconds,
        ));

        if (count($recent) >= $this->max) {
            $this->hits[$key] = $recent;
            return false;
        }

        $recent[] = $now;
        $this->hits[$key] = $recent;
        return true;
    }
}

final class LoggingRateLimiter implements RateLimiter
{
    /** @var list<string> */
    private array $log = [];

    public function __construct(private readonly RateLimiter $inner) {}

    public function allow(string $key): bool
    {
        $allowed = $this->inner->allow($key);
        $this->log[] = sprintf('%s %s', $allowed ? 'allow' : 'deny', $key);
        return $allowed;
    }

    public function log(): array { return $this->log; }
}

// Everything is assembled here, where it can be seen.
$clock = new FrozenClock(new DateTimeImmutable('@10000'));
$limiter = new LoggingRateLimiter(new WindowRateLimiter($clock, max: 3, windowSeconds: 60));

foreach (['aisha', 'aisha', 'aisha', 'aisha', 'noshad'] as $key) {
    printf("%-7s %s%s", $key, $limiter->allow($key) ? 'allowed' : 'DENIED', PHP_EOL);
}

$clock->advance('+61 seconds');
printf("%safter the window:%s", PHP_EOL, PHP_EOL);
printf("%-7s %s%s", 'aisha', $limiter->allow('aisha') ? 'allowed' : 'DENIED', PHP_EOL);

printf("%slog: %s%s", PHP_EOL, implode(', ', $limiter->log()), PHP_EOL);

// The same limiter with a different budget, which a static Config
// could not have given us.
$strict = new WindowRateLimiter($clock, max: 1, windowSeconds: 60);
var_dump($strict->allow('aisha'));          // true
var_dump($strict->allow('aisha'));          // false

Open this example in the editor and change it. Nothing you type is saved.

Three things that a static-heavy version could not do. The clock is injected, so the test advances time by sixty-one seconds without sleeping. The logging wrapper is a separate class implementing the same interface, so the limiter knows nothing about it. And the second limiter has a different budget in the same process, which a class-level static $max makes impossible.

When it goes wrong

Symptom Cause
instanceof on a trait is a fatal error A trait is not a type; add an interface
Two traits conflict and the class will not compile Use insteadof, or use objects instead
A trait overrode the parent class Trait beats inherited; class beats trait
Tests pass alone and fail together Static state leaking between tests
You cannot tell what a class needs Hidden static dependencies; inject them
Two instances cannot differ Class-level state; move it to the instance
A subclass hierarchy has combinatorial names Compose wrappers instead of extending
private in a trait collided with the class Traits are copied in; rename one

A check you can run

Find a class in your codebase that calls a static method on another class of yours - a Config::get, a Cache::remember, a Log::info. Try to write a unit test for it that supplies a different value.

If you cannot do it without a global setUp, a reset() call or a filesystem fixture, you have found the cost of that static. Now add the dependency to the constructor and write the test again. The test gets shorter, it stops depending on order, and the class's signature now tells the truth about what it needs.