__debugInfo() does not hide anything from dd()
The card object in my Laravel package kept its number in a private property, masked it in __debugInfo(), and marked the constructor arguments #[SensitiveParameter]. dd() printed the full card number and CVN anyway, and so did var_export() and an (array) cast. The fix was to stop storing them on the object at all.
laravel-merchantsuite is a client for a card payments API. Its CardDetails object is the one place in the package that holds a raw card number and CVN, so it is the one object that must never end up in a log, a dump or an error report. Version 2.0.0 shipped it like this, trimmed:
final class CardDetails { public readonly Expiry $expiry; private readonly string $number; public function __construct( #[SensitiveParameter] string $number, Expiry|string $expiry, #[SensitiveParameter] private readonly ?string $cvn = null, public readonly ?string $name = null, ) { // validation... $this->number = $digits; } public function __debugInfo(): array { return [ 'number' => $this->masked(), 'expiry' => (string) $this->expiry, 'cvn' => $this->cvn === null ? null : '***', 'name' => $this->name, ]; } public function __serialize(): array { throw new LogicException('CardDetails cannot be serialised.'); } }
Three layers: the properties are private, __debugInfo() returns a masked view, and the constructor arguments are redacted from stack traces. The docblock said the number was hidden from var_dump(), print_r(), serialisation and stack traces, and all four of those were true. This is what dd() printed:
DK\MerchantSuite\Data\CardDetails {#2
+expiry: DK\MerchantSuite\Data\Expiry {#5
+month: "05"
+year: "29"
}
-number: "5123456789012346"
-cvn: "987"
+name: "Jane Citizen"
number: "512345...346"
expiry: "05/29"
cvn: "***"
name: "Jane Citizen"
}The real properties in full, with the masked version appended underneath.
What __debugInfo() covers
PHP consults __debugInfo() from var_dump(), print_r() and debug_zval_dump(), and nothing else in the language does.
final class Card { public function __construct(private string $number) {} public function __debugInfo(): array { return ['number' => substr($this->number, 0, 6).'...']; } } $card = new Card('5123456789012346'); var_dump($card); // ["number"]=> string(9) "512345..." print_r($card); // [number] => 512345... var_export($card); // \Card::__set_state(array('number' => '5123456789012346')) array_values((array) $card); // ["5123456789012346"]
var_export() has to produce code that rebuilds the object, so it reads the real properties. An (array) cast returns every property, private ones under a mangled "\0Card\0number" key. json_encode() sees public properties only, which is why it never leaked here, and get_object_vars() inside the class and Reflection see everything. __debugInfo() is a display hint for the dump functions, not an access control.
What VarDumper does with it
Laravel’s dd() and dump() are Symfony VarDumper, and VarDumper reads __debugInfo() too. It just does not stop there. The object caster, trimmed from Caster::castObject():
if ($hasDebugInfo) { $debugInfo = $obj->__debugInfo(); } $a = (array) $obj; // every real property, private included // ... if ($hasDebugInfo && \is_array($debugInfo)) { foreach ($debugInfo as $k => $v) { $k = self::PREFIX_VIRTUAL.$k; // appended as virtual entries $a[$k] = $v; } } return $a;
The cast comes first and the debug info is added on top as virtual keys. That is the dump above: -number and -cvn are the cast, the unprefixed number and cvn lines under them are the virtual entries. Anything that hooks VarDumper’s handler to collect dumps gets the same array.
#[SensitiveParameter] is narrower than it sounds as well. It replaces the argument with a SensitiveParameterValue in stack traces, and that is all. On a promoted constructor property the parameter is redacted in the trace and the property holds the plain value, ready for the next dd().
Keep the value off the object
If a dumper can walk the object’s properties, the only way to keep a value out of every dumper is for it not to be a property. The package now stores secrets in a static WeakMap keyed by the object that owns them:
final class Secrets { /** @var WeakMap<object, array<string, string|null>>|null */ private static ?WeakMap $store = null; public static function put(object $owner, array $values): void { self::$store ??= new WeakMap; self::$store[$owner] = $values; } public static function get(object $owner, string $key): ?string { return self::$store[$owner][$key] ?? null; } }
CardDetails keeps a precomputed mask as its only private property and reads the real values back when it builds the request:
public function __construct( #[SensitiveParameter] string $number, Expiry|string $expiry, #[SensitiveParameter] ?string $cvn = null, public readonly ?string $name = null, ) { // validation... $this->masked = substr($digits, 0, 6).'...'.substr($digits, -3); Secrets::put($this, ['number' => $digits, 'cvn' => $cvn]); } public function toArray(bool $withCvn = true): array { return array_filter([ 'number' => Secrets::get($this, 'number'), 'expiry' => $this->expiry->toArray(), 'cvn' => $withCvn ? Secrets::get($this, 'cvn') : null, 'name' => $this->name, ], fn ($v) => $v !== null); }
A WeakMap holds its keys weakly: the entry does not keep the card object alive, and it disappears when the object is collected. Two cards are two entries, unset one and the count drops to one. There is no cleanup to forget. It needs PHP 8.0, which is older than anything Laravel 12 runs on.
The same dd() now prints:
DK\MerchantSuite\Data\CardDetails {#2
+expiry: DK\MerchantSuite\Data\Expiry {#5
+month: "05"
+year: "29"
}
-masked: "512345...346"
+name: "Jane Citizen"
}var_export(), the array cast, var_dump() and print_r() show the same three things. __debugInfo() is gone, because there is nothing left to hide and the masked property gives a dump something useful to say.
Two consequences follow from taking the value off the object, and the class has to deal with both:
clonecopies properties, notWeakMapentries. A cloned card would validate, dump cleanly and send an empty card number to the gateway.__clone()throws.serialize()would do the same, so__serialize()and__unserialize()throw too. 2.0.0 already refused to serialise; the clone case is new.
The real values leave the object through toArray() and nowhere else, and that is the call that builds the API request.
The test that keeps it that way
One test, one dataset per dumper:
it('keeps the card number and cvn out of every kind of dump', function (Closure $dump) { $output = $dump(new CardDetails('5123456789012346', '05/29', '987', 'Jane Citizen')); // Dumpers print object handles (#987); once a long run has created // enough objects, one can equal the CVN. $output = (string) preg_replace('/#\d+/', '#', $output); expect($output)->not->toContain('5123456789012346') ->not->toContain('987'); })->with([ 'dd() / dump()' => fn ($card) => (new CliDumper)->dump((new VarCloner)->cloneVar($card), true), 'var_dump' => function ($card) { ob_start(); var_dump($card); return (string) ob_get_clean(); }, 'var_export' => fn ($card) => var_export($card, true), 'print_r' => fn ($card) => print_r($card, true), 'json_encode' => fn ($card) => (string) json_encode($card), '(array) cast' => fn ($card) => (string) json_encode((array) $card), ]);
The preg_replace line is there because of a failure with no leak behind it. VarDumper and var_dump() print object handles, the #2 and #5 in the dumps above. In a full run on PHP 8.5 the test object happened to be the 987th object created, the dump contained #987, and the assertion that the CVN 987 was absent failed. Strip the handles before asserting, or pick a CVN no handle will ever reach.
If you have a value object holding something you would not want in a log, run it through those six lines once. The two you probably checked are the two that __debugInfo() covers.