Skip to content

Instantly share code, notes, and snippets.

@ghostwriter
Created January 6, 2026 16:46
Show Gist options
  • Select an option

  • Save ghostwriter/ebd8f590c8f7c49ca18e4a5f7754b0d9 to your computer and use it in GitHub Desktop.

Select an option

Save ghostwriter/ebd8f590c8f7c49ca18e4a5f7754b0d9 to your computer and use it in GitHub Desktop.
Traits Don’t Make Classes Cleaner, They Just Hide the Mess

Traits are often justified with a single, seductive argument:

“It makes the class look cleaner.”

Fewer lines. Less scrolling. A tidy façade.

This post explains why that argument is wrong—and why traits frequently increase complexity, coupling, and long-term maintenance cost, especially in PHP.


What “Cleaner” Usually Means (and Why It’s a Trap)

When people say cleaner, they usually mean:

  • Fewer methods visible in the class
  • Less vertical space
  • Less duplication at a glance

None of those measure:

  • Coupling
  • Encapsulation
  • Reasoning cost
  • Refactor safety
  • Testability

Traits optimize for visual simplicity, not architectural simplicity.


Traits Are Implicit Inheritance

A trait is not composition.
It is lexical inclusion.

class OrderService
{
    use LoggingTrait;
}

This looks harmless—until you open the trait.

trait LoggingTrait
{
    protected LoggerInterface $logger;

    protected function log(string $message): void
    {
        $this->logger->info($message);
    }
}

Questions you now have to answer:

  • Who initializes $logger?
  • Is it always available?
  • What happens if the class already has a log() method?
  • Is the logger required for all use cases?

None of this is visible from the class itself.

The dependency exists, but it’s invisible.


Traits Destroy Local Reasoning

With composition, you can understand a class by reading its constructor.

final class OrderService
{
    public function __construct(
        private LoggerInterface $logger,
    ) {}
}

With traits, behavior and assumptions are scattered:

  • Properties may be declared elsewhere
  • Methods may be overridden elsewhere
  • Lifecycle hooks may be injected elsewhere

To understand the class, you must:

  1. Find all traits
  2. Read them in the correct order
  3. Merge behavior mentally
  4. Hope nothing conflicts

That is not “clean.”


Hidden Coupling in Practice

Consider a “cleaned up” class:

final class UserController
{
    use ValidationTrait;
    use JsonResponseTrait;

    public function store(array $input): array
    {
        $this->validate($input);
        return $this->json(['ok' => true]);
    }
}

Now inspect the traits.

trait ValidationTrait
{
    protected function validate(array $input): void
    {
        if (!$this->validator->isValid($input)) {
            throw new ValidationException();
        }
    }
}
trait JsonResponseTrait
{
    protected function json(array $data): array
    {
        return $this->serializer->serialize($data);
    }
}

Surprise dependencies:

  • $this->validator
  • $this->serializer

None are declared.
None are enforced.
None are visible.

This is runtime coupling with no type safety.


Traits Have Inheritance Problems Without Inheritance Benefits

Traits share many problems with inheritance:

  • Fragile method overrides
  • Implicit contracts
  • Tight coupling to internals
  • Non-local effects

But they lack inheritance’s strengths:

Feature Inheritance Traits
Polymorphism
Type-hintable
Mockable
Explicit contract

Traits give you the risks without the tooling support.


Refactoring Becomes Dangerous

Changing a trait is a global change.

trait CacheAwareTrait
{
    protected function get(string $key): mixed
    {
        return $this->cache->get($key);
    }
}

Rename get() → silently breaks consumers
Add a method → may override class behavior
Change assumptions → runtime errors elsewhere

Static analysis can’t protect you because the dependency graph is implicit.


Testing Gets Worse, Not Better

You cannot:

$this->createMock(SomeTrait::class);

Traits:

  • Cannot be tested in isolation
  • Must be tested through consuming classes
  • Inflate test fixtures
  • Increase mocking complexity

A service can be mocked.
A trait cannot.

That alone should raise alarms.


Composition Solves All of This

Let’s rewrite one of the earlier examples.

Before (Trait)

final class UserController
{
    use ValidationTrait;

    public function store(array $input): void
    {
        $this->validate($input);
    }
}

After (Composition)

final class UserController
{
    public function __construct(
        private Validator $validator,
    ) {}

    public function store(array $input): void
    {
        $this->validator->validate($input);
    }
}

Benefits:

  • Dependency is explicit
  • Constructor documents requirements
  • Validator is mockable
  • Behavior is discoverable
  • Refactors are localized

Yes, the class is longer.

It is also simpler.


When Traits Are Actually Acceptable

Traits can be fine if they are:

  • Stateless
  • Side-effect free
  • Dependency free
  • Mechanically focused

Examples:

trait ToArrayTrait
{
    public function toArray(): array
    {
        return get_object_vars($this);
    }
}

Rule of thumb:

If a trait needs $this->something, it probably shouldn’t be a trait.


The Real Cost of “Cleaner”

Traits reduce visible code while increasing:

  • Invisible dependencies
  • Cognitive load
  • Refactor risk
  • Test complexity
  • Architectural entropy

That’s not cleanliness—that’s concealment.


A Better Heuristic

Traits are for mechanics.
Composition is for behavior.

Or, more bluntly:

If your reason for using a trait is “it looks cleaner,”
you’re optimizing for aesthetics over correctness.

And that debt always comes due.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment