skip to content

In a PHP CMS where Article, Page and MediaFile need timestamps and soft deletes, when is a trait the right tool, and how do you stop it becoming hidden coupling?

level: seniorimportance: should knowfreq 30%

answer

  1. same code, unrelated parents
  2. pair the trait with an interface
  3. abstract methods state the needs
  4. dependencies still come from outside
  5. test through an anonymous class

basics

~20 s

A trait fits small, cohesive behaviour that several unrelated classes need verbatim, such as timestamp and soft-delete fields. Keep it honest by pairing it with an interface, declaring its needs as abstract methods, and leaving real dependencies to injected services.

solid answer

~50 s

A trait is the right tool when classes in **different hierarchies** need the **same small behaviour** and that behaviour works on the object's own state: `createdAt`/`updatedAt` handling or a `deletedAt` flag in CMS entities. It becomes hidden coupling when its methods silently read host properties the trait never declared, call `parent::`, or reach for globals and static singletons. Guardrails: pair the trait with an **interface** (`SoftDeletable`) so callers depend on a type; declare what the trait needs as **abstract methods** (`abstract protected function now(): DateTimeImmutable;`) so a host that forgets fails at declaration; keep the trait's state private and its surface small; and leave anything needing collaborators to an **injected service**, because a trait has no constructor separate from the host's: a `__construct()` in a trait simply becomes the host's constructor, and a host constructor replaces it. Test the trait on its own by composing it into an anonymous class.

code

php · 37 lines
php
<?php
declare(strict_types=1);

interface SoftDeletable
{
    public function softDelete(): void;
    public function isDeleted(): bool;
}

trait SoftDeletes
{
    private ?DateTimeImmutable $deletedAt = null;

    abstract protected function now(): DateTimeImmutable;

    public function softDelete(): void
    {
        $this->deletedAt = $this->now();
    }

    public function isDeleted(): bool
    {
        return $this->deletedAt !== null;
    }
}

$entity = new class implements SoftDeletable {
    use SoftDeletes;

    protected function now(): DateTimeImmutable
    {
        return new DateTimeImmutable('2026-01-01');
    }
};

$entity->softDelete();
var_dump($entity->isDeleted()); // bool(true)

go deeper

for a junior

Recall that traits share identical code across classes that cannot share a parent, and that an interface is still needed when callers want a type.

for a middle

Explain the guardrails: abstract methods for requirements, an interface for the type, private state, and testing through an anonymous class.

for a senior

Judge when timestamp or soft-delete logic belongs in a trait versus an injected service, based on collaborators, variation between hosts and testability, and defend that call in review.

for a principal

Set codebase-wide guidance on traits: which behaviours qualify, required interface pairing, and how to retire traits that have accreted dependencies.

## What a trait is good at A **trait** copies code into each class that uses it, without taking the class's single parent slot. That makes it a good fit for a narrow kind of problem: - **Several unrelated classes** need the behaviour. In a CMS, `Article` might extend a versioned-content base, `Page` a tree-node base and `MediaFile` a storage-backed base; none can share a new parent. - The behaviour is **the same everywhere**: setting `updatedAt` on change, flagging `deletedAt` instead of deleting. - It operates mainly on **the object's own state** and needs few or no collaborators. Timestamps and soft deletes fit all three, which is why this scenario is the textbook case for traits in PHP applications. ## Where traits turn into hidden coupling The same copy-and-paste mechanism that makes traits convenient lets them lean on things nobody declared: 1. **Undeclared host members.** A trait method that reads `$this->id` compiles in every host, and fails only at runtime in a host without `id`. 2. **`parent::` calls.** A trait that calls `parent::save()` works only in hosts whose parent has `save()`. 3. **Global or static collaborators.** A trait that grabs a clock, logger or connection from a static registry hides a dependency that tests then have to fake globally. 4. **No type.** Code that needs "anything soft-deletable" cannot use the trait name as a type; teams then check `method_exists()` or `class_uses()`, which is brittle. 5. **Private state leaks into the host.** Trait properties become the host's own, so a host can overwrite `$deletedAt` directly and bypass the trait's rules. ## Guardrails that keep a trait honest | Risk | Guardrail | |---|---| | undeclared host members | declare them as **abstract methods** in the trait, so a host that lacks one fails at declaration | | no type for callers | pair the trait with an **interface** and have every host implement it | | hidden collaborators | pass collaborators into the host (constructor injection) and expose them to the trait through an abstract accessor, or move the behaviour into a service | | state bypass | keep trait properties `private` and review hosts that touch them | | accidental method shadowing | give trait methods specific names; use `as` aliases when a host must extend one | The interface-plus-trait pairing is the one to insist on: `interface SoftDeletable { public function softDelete(): void; public function isDeleted(): bool; }`, implemented by every entity with `use SoftDeletes;` supplying the bodies. Repositories then accept `SoftDeletable`, and a future entity may implement it without the trait. ## When to choose something else - **The behaviour needs collaborators** (a database, a queue, a clock with configuration). A trait has no constructor separate from the host's (a trait `__construct()` becomes the host's constructor, and a host constructor replaces it), so an injected service such as a `SoftDeleteService` is cleaner. - **The behaviour varies per class.** Then an interface with separate implementations, or a strategy object, expresses the variation; a trait with many overridden methods is inheritance in disguise. - **All the classes already share a parent.** Putting the code in that parent is simpler than a trait. ## Testing a trait on its own Because a trait cannot be instantiated, tests compose it into a throwaway **anonymous class** that satisfies the abstract methods: 1. Create `new class { use SoftDeletes; protected function now(): DateTimeImmutable { return new DateTimeImmutable('2026-01-01'); } }`. 2. Call `softDelete()` and assert on `isDeleted()` and the timestamp. 3. Keep one integration test per real host to catch incompatible property or method definitions. This tests the trait's contract without dragging in any entity's persistence.

  • Why not let the SoftDeletes trait create its own clock with new DateTimeImmutable() every time?
    It works, but every test of every host then depends on the real time, and nothing can freeze it. An abstract `now()` pushes the decision to the host, which can return a value from an injected clock. The trait stays simple and its dependency is visible in its declaration.
  • The team adds a restore() that must also re-index search. Does that belong in the trait?
    Not the re-indexing. Resetting `deletedAt` is the trait's own state, but re-indexing needs a search client, which the trait could only obtain from the host. Keep `restore()` state-only in the trait and let a service, or the entity's owner, trigger re-indexing after restore.

saying these in an interview costs you the question

  • Traits are the preferred way to share any code between classes
  • A trait can inject its own constructor dependencies separately from the host
  • Callers can type parameters against the trait to accept all hosts
  • A trait reading $this->id is safe because every entity has an id
  • Traits cannot be tested without a real host class