Working notesOctober 12, 202610 min read

Why I Avoid PHP Traits

I want to add a private method without checking the internals of every trait the class uses. PHP's trait mechanics make that harder than it should be.

Separate responsibilities

I don’t want to read every trait a class uses before adding a private method to it. But with PHP traits, that’s part of the job. A method I add to the class can take precedence over a helper inside a trait, changing behaviour I wasn’t working on.

This is what bothers me about them. Extracting code into a trait looks like separating it. Then I come back to change something and find that the two files still need to know quite a lot about each other.

A small example makes this easier to see.

The export was working

Suppose we’re exporting invoice amounts to an accounting system. It expects 1234.56, with a decimal point and no thousands separator. Two callers need the same formatting, so we put it in a trait.

The examples use plain PHP 8.2 or later, with the opening <?php tags omitted. Amounts are non-negative integer cents in one currency. They’re small enough that the division here preserves cent precision.

trait ExportsInvoiceFields
{
    /** @return array{id: int, amount: string} */
    public function exportFields(int $id, int $amountCents): array
    {
        return [
            'id' => $id,
            'amount' => $this->formatAmount($amountCents),
        ];
    }

    private function formatAmount(int $cents): string
    {
        return number_format($cents / 100, 2, '.', '');
    }
}

final class InvoiceReport
{
    use ExportsInvoiceFields;
}

$report = new InvoiceReport();

// ['id' => 42, 'amount' => '1234.56']
$fields = $report->exportFields(42, 123456);

Fine so far. The method takes its inputs, formats them, and returns a value. The helper is private. It doesn’t reach into a model or save anything behind our backs.

Now the report needs a label for someone reading it on screen. We add a formatter that includes a thousands separator. Here’s the new InvoiceReport; the trait stays exactly as it was.

final class InvoiceReport
{
    use ExportsInvoiceFields;

    public function amountLabel(int $amountCents): string
    {
        return $this->formatAmount($amountCents);
    }

    private function formatAmount(int $cents): string
    {
        return number_format($cents / 100, 2, '.', ',');
    }
}

$report = new InvoiceReport();

// '1,234.56' — correct for the display label.
$label = $report->amountLabel(123456);

// ['id' => 42, 'amount' => '1,234.56'] — wrong for the accounting system.
$fields = $report->exportFields(42, 123456);

The label is correct. The export has changed too.

Inside the trait, $this->formatAmount() now calls the report’s method. PHP gives a method declared on the class precedence over a trait method, including in this case where both are private. There’s no conflict error. These are the documented method precedence rules.

I find this easy to miss because private usually tells me I can deal with a helper inside its own implementation. Here it means private to the assembled class. The trait doesn’t get an implementation scope of its own.

Both formatters do one small job. Neither is badly written. They happen to have the same name, and that is enough to connect two bits of behaviour that should be able to change separately.

Of course I can rename the trait’s helper to formatInvoiceExportAmount(). I’d probably do that if I were fixing this in an existing application. But now I have to account for that name in every consuming class. Add another helper to the trait later and there’s another name to check.

That’s a lot of coordination for a private method.

Moving both methods into traits doesn’t help

If the display formatter lived in a second trait, PHP would reject the conflicting formatAmount() methods until we resolved them. At least that version fails where I can see it.

The resolution still needs care. insteadof selects which implementation gets the shared name. as gives a method an additional name; it doesn’t rewrite calls inside the trait to use that alias. The manual explains both rules.

So selecting the export formatter can make the display path call it too. Selecting the display formatter can break the export. I still have to change the internal names or arrange for the methods to cooperate.

I wanted to reuse some formatting. I’m now managing how two implementations share a method namespace.

An abstract class is different here

An abstract class avoids this particular bug. Move the export code into a parent class and its private formatter stays private to that parent. The child can have a method with the same name without replacing it.

Here’s an alternative to the trait version, using the same inputs:

abstract class InvoiceExport
{
    /** @return array{id: int, amount: string} */
    public function exportFields(int $id, int $amountCents): array
    {
        return [
            'id' => $id,
            'amount' => $this->formatAmount($amountCents),
        ];
    }

    private function formatAmount(int $cents): string
    {
        return number_format($cents / 100, 2, '.', '');
    }
}

final class InvoiceReport extends InvoiceExport
{
    public function amountLabel(int $amountCents): string
    {
        return $this->formatAmount($amountCents);
    }

    private function formatAmount(int $cents): string
    {
        return number_format($cents / 100, 2, '.', ',');
    }
}

$report = new InvoiceReport();

// '1,234.56'
$label = $report->amountLabel(123456);

// ['id' => 42, 'amount' => '1234.56']
$fields = $report->exportFields(42, 123456);

Both outputs are correct. The parent’s call uses its own private helper. This comes from inheritance and private visibility, not from the abstract keyword. A concrete parent would behave the same way.

Make both formatters protected, though, and the child’s override changes the export again. Parent and child still share $this, and calls to overridable methods can run child code. Shared protected state creates similar dependencies. An abstract class doesn’t make that coupling disappear; private members let me keep some implementation details out of it.

So I wouldn’t call them the same problem. The trait version loses a private boundary that inheritance preserves. That’s a real difference in this example.

There are tradeoffs in the other direction. An abstract class establishes a type relationship and takes the class’s single parent slot. Traits let unrelated classes reuse code while keeping their existing parents. That’s useful, especially in framework code, but it also lets several sets of methods end up in the same class scope.

I still wouldn’t make this report extend InvoiceExport just to borrow a formatter. It ties the report to a parent implementation, and I can’t choose a different exporter for one report instance. I’d use an abstract base when I actually want a family of classes following a shared implementation with deliberate extension points. Here I just want to call some formatting code.

What I’d write instead

I’d give the exporter its own object. Same formatting, same inputs. This replaces the trait and the earlier report class.

final class InvoiceFieldExporter
{
    /** @return array{id: int, amount: string} */
    public function exportFields(int $id, int $amountCents): array
    {
        return [
            'id' => $id,
            'amount' => $this->formatAmount($amountCents),
        ];
    }

    private function formatAmount(int $cents): string
    {
        return number_format($cents / 100, 2, '.', '');
    }
}

final class InvoiceReport
{
    public function __construct(
        private InvoiceFieldExporter $exporter,
    ) {}

    /** @return array{id: int, amount: string} */
    public function exportFields(int $id, int $amountCents): array
    {
        return $this->exporter->exportFields($id, $amountCents);
    }

    public function amountLabel(int $amountCents): string
    {
        return $this->formatAmount($amountCents);
    }

    private function formatAmount(int $cents): string
    {
        return number_format($cents / 100, 2, '.', ',');
    }
}

$report = new InvoiceReport(new InvoiceFieldExporter());

// '1,234.56'
$label = $report->amountLabel(123456);

// ['id' => 42, 'amount' => '1234.56']
$fields = $report->exportFields(42, 123456);

Now $this inside the exporter is the exporter. Its formatAmount() and the report’s formatAmount() can coexist without either knowing about the other.

There’s more wiring. A constructor, a property, a forwarding method to keep the report’s existing API. I’m fine with those lines. I can read the call and see which object does the work. If callers don’t need to go through the report, they can use the exporter directly and the forwarding method goes away.

I haven’t added an interface. There’s one export format here. If I later need interchangeable exporters, I’ll add one then.

The useful change is small: I can edit the report’s private helpers without changing what the exporter calls.

It gets less obvious with state

The example above doesn’t even use properties. A trait can also read the consuming class’s private state, so $this->invoice inside a trait can mean a property declared somewhere else entirely.

That works until I change how the class stores its invoice. Maybe it now holds an identifier and loads the data only when needed. Its public methods can stay the same, but the trait still expects an object in that property. I have to find and update that assumption too.

Then another trait calls a method from the first one, and the class reads a property supplied by the second. Nothing about the individual files has to look especially complicated. I just have to read them together before I can safely change any of them.

PHP does give us ways to make this clearer. Abstract trait methods can require the host to provide a method with a compatible signature. I’d rather see a required invoice() method than an unexplained property access. The abstract-member documentation shows how that works, and static analysis helps with other assumptions.

But the methods still run on the same object. Making the agreement explicit doesn’t separate their implementations.

The exporter above gets two integers. It has no reference to the report, so it can’t start depending on the report’s private invoice property. I don’t have to remember that rule each time I edit it.

The tests can miss the interesting part

Put the trait in an empty test class and the original export test passes. Test the report’s new label and that passes too. The failure is in the combination: the actual report exports through its own formatter.

For that version, I’d test both outputs on the real report. The label should contain a thousands separator; the exported amount shouldn’t. A dummy host doesn’t reproduce the method precedence that caused the bug.

With the separate object, I can test the exporter directly and check that the report passes the right inputs. Adding a private helper to the report cannot change the exporter’s internal calls. That’s one less interaction I need to keep in my head.

There are other practical limits. A trait isn’t an interface I can use as a caller’s type contract, and it isn’t a dependency I can swap for one instance of a report. If I need either of those things, I have more work to do. Sharing an implementation doesn’t give me them automatically.

I still use framework traits where they’re the supported way to enable a feature. I’m not going to rebuild Laravel internals to avoid a use statement. And when the whole point is to participate in the host’s internals, a trait can be an honest expression of that relationship.

It’s the default reach for a trait that I dislike. Two classes need a method, so we mix it into both and treat the reuse as settled. I’d rather spend a little longer deciding what data the shared code needs and whether it should have access to the rest of either class.

For this exporter, two integers are enough. I’ll write the constructor.

Next post

Iteration is the Key to Software