Enums That Explain Themselves: __debugInfo() in PHP 8.6

PHP 8.6 allows __debugInfo() on enums. Add a readable debug view to a Laravel status enum, see where it shows up, and what it deliberately leaves alone.

Steven Richardson
Steven Richardson
· 7 min read

An OrderStatus enum in a real Laravel app is never just two cases and a string. It has a label() for the UI, a color() for the badge, and a canTransitionTo() that encodes the state machine. Then you hit a transition bug, type dd($order->status), and get back name and value — the two things you already knew. PHP 8.6 fixes that with one magic method.

Confirm your PHP version allows __debugInfo() on an enum#

The original enumerations RFC banned every magic method except __call(), __callStatic() and __invoke(), on the reasoning that the rest involve state and enum cases have none. __debugInfo() got caught in that net despite needing no state at all. The Debugable Enums RFC by Daniel Scherzer passed 16–2 in April 2026 and ships in PHP 8.6 on 19 November 2026.

The change is small: the fatal error is removed, and the usual validation of the magic method still applies — public, no parameters, returns an array.

<?php

enum Foo: string {
    case Bar = 'Baz';

    public function __debugInfo(): array
    {
        return [__CLASS__ . '::' . $this->name . ' = ' . $this->value];
    }
}

var_dump(Foo::Bar);
enum(Foo::Bar) (1) {
  [0]=>
  string(14) "Foo::Bar = Baz"
}

Note the header. An earlier draft of the RFC dropped enum(Foo::Bar) and the final implementation keeps it, so you never lose the case identity no matter what you return. On PHP 8.5 and below the same code is a fatal error at compile time, so this is strictly an 8.6-and-up pattern — there is no polyfill for a parser rule.

Add __debugInfo() to a status enum#

Put the derived values in the array, not the raw ones. The point is to surface what you would otherwise have to call by hand, so return the label, the badge colour and the legal next states alongside the identity.

<?php

declare(strict_types=1);

namespace App\Enums;

enum OrderStatus: string
{
    case AwaitingPayment = 'awaiting_payment';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        return match ($this) {
            self::AwaitingPayment => 'Awaiting payment',
            self::Paid => 'Paid',
            self::Shipped => 'Shipped',
            self::Cancelled => 'Cancelled',
        };
    }

    public function color(): string
    {
        return match ($this) {
            self::AwaitingPayment => 'amber',
            self::Paid => 'blue',
            self::Shipped => 'green',
            self::Cancelled => 'red',
        };
    }

    /** @return list<self> */
    public function allowedTransitions(): array
    {
        return match ($this) {
            self::AwaitingPayment => [self::Paid, self::Cancelled],
            self::Paid => [self::Shipped, self::Cancelled],
            self::Shipped, self::Cancelled => [],
        };
    }

    public function __debugInfo(): array
    {
        return [
            'label' => $this->label(),
            'color' => $this->color(),
            // Map to names — returning the cases themselves nests four more dumps.
            'allowed_transitions' => array_map(
                static fn (self $status): string => $status->name,
                $this->allowedTransitions(),
            ),
        ];
    }
}

That array_map matters more than it looks. Return the case objects and every dump of one status recursively dumps its successors, each of which dumps theirs. Names keep the output one screen tall.

If the enum itself is new to you, backed enums as Eloquent model casts covers the casting side, and enums with methods and interfaces covers the behaviour this method is exposing.

Read the new output in var_dump, dd() and Ray#

The two surfaces you use every day behave differently, and knowing which is which saves a confusing five minutes. var_dump() replaces the body entirely — name and value are gone from the properties, surviving only in the header. Symfony's VarDumper, which is what Laravel's dd() and dump() sit on, does the opposite: it casts the case to an array first and then merges your __debugInfo() entries on top as virtual properties.

So the same enum dumped two ways:

var_dump($order->status);
// enum(App\Enums\OrderStatus::Shipped) (3) {
//   ["label"]=> string(7) "Shipped"
//   ["color"]=> string(5) "green"
//   ["allowed_transitions"]=> array(0) { }
// }

dd($order->status);
// App\Enums\OrderStatus Enum
//   name: "Shipped"
//   value: "shipped"
//   label: "Shipped"
//   color: "green"
//   allowed_transitions: []

Nothing in Laravel needs configuring for this. Caster::castObject() checks method_exists($class, '__debugInfo') for every object it clones and enum cases take that same generic path — there is no dedicated enum caster to work around. That means Ray, Laravel Telescope's dump watcher and the Ignition error page's variable inspector all inherit it for free. If Ray is your usual tool, debugging Livewire and Eloquent with Ray is where this pays off most, because enums in a Livewire payload are otherwise opaque.

Confirm serialization and JSON output are unchanged#

This is the part worth proving to yourself before you put derived data in the array, because it is what makes the feature safe. __debugInfo() is a view, not a contract. Nothing that leaves your application observes it.

$status = OrderStatus::Shipped;

json_encode($status);           // "shipped"  — BackedEnum, not __debugInfo()
json_encode(['s' => $status]);  // {"s":"shipped"}
serialize($status);             // enum "App\Enums\OrderStatus:Shipped"

$order->status = $status;
$order->save();                 // writes 'shipped' — Eloquent casting is untouched

match ($status) {               // match arms compare identity, not debug output
    OrderStatus::Shipped => true,
    default => false,
};

$status === OrderStatus::Shipped; // true

JSON encoding of a backed enum comes from BackedEnum, and if you want to change it you implement JsonSerializable — a separate decision with real API consequences. Keeping those two apart is the whole design: you can put anything useful in the debug array, including values computed from config or translated strings, without a single caller downstream noticing.

Extract the pattern into a DebuggableEnum trait#

Writing the same three-key array on nine enums is how the pattern dies. Define the shape once in a trait, let each enum contribute through the methods it already has, and apply it with a single use.

<?php

declare(strict_types=1);

namespace App\Enums\Concerns;

trait DebuggableEnum
{
    public function __debugInfo(): array
    {
        $info = [];

        if (method_exists($this, 'label')) {
            $info['label'] = $this->label();
        }

        if (method_exists($this, 'color')) {
            $info['color'] = $this->color();
        }

        if (method_exists($this, 'allowedTransitions')) {
            $info['allowed_transitions'] = array_map(
                static fn (self $case): string => $case->name,
                $this->allowedTransitions(),
            );
        }

        return $info;
    }
}

An interface-based version is tidier if your enums already implement something like HasLabel, and Filament users will have that interface in place. The method_exists() version earns its keep when they do not: one trait, no per-enum ceremony, and enums that gain a color() later start showing it without being touched.

enum OrderStatus: string
{
    use DebuggableEnum;
    // ...
}

enum SubscriptionState: string
{
    use DebuggableEnum;
    // label() only — the dump shows label and nothing else
}

Keep the method body cheap and side-effect free#

Treat __debugInfo() as code that runs at the worst possible moment. It fires inside dumps, inside Ray payloads, and inside the variable inspector on a production error page — which is to say, while something is already going wrong. No queries, no container resolution, no HTTP calls, no cache lookups that might miss.

The failure mode is specific and nasty. VarDumper wraps the call in try { $obj->__debugInfo(); } catch (\Throwable) { } and silently drops the result, so a method that throws gives you a dump with your keys missing and no indication why. var_dump() does not swallow it, so the same enum explodes in one surface and quietly degrades in the other.

// Don't do this.
public function __debugInfo(): array
{
    return [
        'label' => $this->label(),
        // A query on an error page, for every dumped case, in a loop.
        'open_orders' => Order::where('status', $this->value)->count(),
    ];
}

If the context you want really does need a lookup, put it in the thing you are dumping rather than the enum — dump($order->status, $order->openCount()) costs one more argument and stays honest. The same discipline applies to custom exceptions that attach report context: anything evaluated during failure handling has to survive the failure.

Wrapping Up#

Add __debugInfo() to the one enum you dump most often, confirm the output in dd(), then promote it to a trait when the second enum wants it. It costs ten minutes and removes a whole category of "dump, call method, dump again" debugging.

This lands with the rest of PHP 8.6 on 19 November 2026, so plan it alongside the Laravel upgrade checklist for PHP 8.6. If you are auditing enums anyway, the new global SortDirection enum is the other 8.6 enum change worth knowing about.

FAQ#

Can PHP enums define __debugInfo()?

From PHP 8.6, yes. Enums were originally barred from declaring any magic method other than __call(), __callStatic() and __invoke(), and the Debugable Enums RFC removed that restriction for __debugInfo() specifically. On PHP 8.5 and earlier the same code is a compile-time fatal error, so there is no way to polyfill it or guard it at runtime.

What does __debugInfo() do in PHP?

It lets an object control what debugging functions display for it. The method takes no arguments and returns an array, and var_dump() prints that array in place of the object's real properties. It has existed for ordinary classes since PHP 5.6; PHP 8.6 is simply the version that stops rejecting it on enums.

Does __debugInfo() change how an enum is serialized or JSON encoded?

No. json_encode() on a backed enum still emits the backing value because that behaviour comes from BackedEnum, and changing it means implementing JsonSerializable instead. serialize(), Eloquent attribute casting, match arms and === comparisons are equally unaffected. That separation is deliberate — it is what makes it safe to put derived or translated data in the debug array.

Does Laravel's dd() respect __debugInfo() on an enum?

Yes, with one difference worth knowing. dd() and dump() use Symfony's VarDumper, whose generic object caster calls __debugInfo() whenever the method exists and merges the result as virtual properties. Because it merges rather than replaces, you still see name and value above your own keys, where plain var_dump() shows only the array you returned.

Should I put database queries in __debugInfo()?

No. The method can run on a production error page while the request is already failing, and often once per dumped case inside a loop. Worse, VarDumper catches and discards any exception thrown from it, so a failing query leaves you with a silently incomplete dump and no error to chase. Keep the body pure, fast and dependent only on the case itself.

What PHP version added debugable enums?

PHP 8.6, scheduled for release on 19 November 2026. The RFC was authored by Daniel Scherzer, passed by 16 votes to 2 with 5 abstentions in April 2026, and implemented in php-src pull request 21425.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.