---
name: php-array-cast-private-property-mangling
description: |
  Catch a silent fail-OPEN bug where a PHP validation / security / readiness
  check reads fields off a value-object or DTO via `(array)$object` instead of
  an accessor. When the object stores its data in a PRIVATE (or PROTECTED)
  property, the (array) cast returns NUL-byte-mangled keys ("\0ClassName\0prop"
  / "\0*\0prop"), NOT the logical fields — so array_key_exists()/isset()/array
  access on the expected key are ALWAYS false and the check becomes a no-op.
  Use when: (1) a guard that reads `$arr['someField']` after `$x = (array)$obj`
  or `$x = is_array($v) ? $v : (array)$v;`, (2) a check "passes" in unit tests
  (which mock a collaborator returning raw arrays) but never fires in production
  (where the collaborator returns typed DTO objects), (3) any bankVerified /
  status / isAllowed / permission gate that silently never triggers against real
  data, (4) reviewing code that mixes array and object payloads from one source.
author: Claude Code
version: 1.0.0
date: 2026-06-03
---

# PHP `(array)$object` private-property mangling defeats field checks (fails OPEN)

## Problem

PHP's `(array)` cast on an object does NOT expose logical fields when the data
lives in a **private** or **protected** property. The array keys come back
*mangled* with NUL bytes:

- `private $data`   → key `"\0ClassName\0data"`   (NUL + FQCN + NUL + name)
- `protected $data` → key `"\0*\0data"`
- `public $data`    → key `"data"` (only public props are clean)

So for a typical thin DTO:

```php
class EvereeWorkerDTO {
    private array $data;                 // raw decoded JSON
    public function getRawData(): array { return $this->data; }
}

$w = (array) $dto;
// $w === [ "\0BuyerKiosk\Payroll\DTOs\EvereeWorkerDTO\0data" => [...] ]
array_key_exists('bankVerified', $w);   // ALWAYS false
isset($w['status']);                     // ALWAYS false
$w['anything'];                          // undefined-key warning / null
```

Any guard that reads expected keys off that mangled array silently becomes a
**no-op**. If the guard is a security / readiness / authorization check, it
**fails OPEN** — the protected action proceeds as if the check passed.

### Why it survives tests

This bug hides behind **fixture-vs-reality drift**: the unit tests mock the
upstream collaborator to return *raw associative arrays* (the simple shape),
while production returns *typed DTO objects*. The `is_array($v) ? $v : (array)$v`
branch makes both "work" syntactically, but only the array path actually reads
fields. Green tests, dead gate.

## Context / Trigger Conditions

- A method does `$x = (array)$obj;` (or `$x = is_array($v) ? $v : (array)$v;`)
  then `array_key_exists('field', $x)` / `isset($x['field'])` / `$x['field']`.
- The object is a DTO / value object / entity storing state in private or
  protected properties (very common in `BuyerKiosk\…\DTOs`, `…\Models`).
- A guard, validation, or security check "works in tests" but you suspect it
  never fires in production — or a readiness/verification gate is suspiciously
  always-passing.
- Real-world symptom from spec-050 T15: a company readiness gate
  (`runVerification`, Cross-Feature F6: keep `isActive=0` until bank verified)
  never fired because `listWorkers()` returns `EvereeWorkerDTO[]`, but the
  `bankVerified === false` / `status === 'not_ready'` checks ran on a
  `(array)$dto` mangled key set.

## Solution

Never use `(array)` to read fields off an object with non-public properties.
Unwrap explicitly via the object's accessor:

```php
foreach ($workers as $worker) {
    if ($worker instanceof EvereeWorkerDTO) {
        $w = $worker->getRawData();            // clean top-level keys
    } elseif (is_array($worker)) {
        $w = $worker;                          // raw array (tests / legacy)
    } else {
        $w = (array) $worker;                  // last-resort (public props only)
    }
    if (array_key_exists('bankVerified', $w) && $w['bankVerified'] === false) { ... }
}
```

Alternatives depending on the object:
- Add/use a typed accessor (`$dto->getStatus()`, `$dto->getRawData()`).
- From *inside* the class, `get_object_vars($this)` returns unmangled keys.
- `json_decode(json_encode($obj), true)` works only if the object implements
  `JsonSerializable`/has public props — do NOT rely on it for private-prop DTOs.

Also fix the test that hid it: add a case that feeds the **real production
type** (the DTO object), not a raw array.

## Verification

Prove the guard was actually dead, then prove it's alive:

1. Write a test that feeds the real object type (e.g.
   `EvereeWorkerDTO::fromArray(['bankVerified' => false, ...])`) and asserts the
   gate fires (e.g. the protected write is NOT performed).
2. Temporarily revert the unwrap to `(array)$obj` and run that test — it MUST
   fail (the gate doesn't fire; the protected action runs). This confirms the
   test actually exercises the bug.
3. Restore the unwrap — the test passes.

```bash
# from spec-050 T15 — pre-fix run of the DTO-input test:
#   PayrollTenantRepository::update(...) was not expected to be called
#   (i.e. isActive got flipped on an unverified company → gate was dead)
```

Quick REPL check of the mangling itself:

```php
class D { private $data = [1]; }
var_dump(array_keys((array) new D));
// string "\0D\0data"  ← mangled, not "data"
```

## Example

spec-050 T15 (commit `d6fce3aae`): `EvereeProvisioningService::runVerification()`
read `bankVerified` / `status` off `(array)$worker`. `listWorkers()` returns
`EvereeWorkerDTO` objects (private `array $data`). The F6 bank-not-verified gate
never fired in production; provisioning would flip `isActive=1` on an unverified
company. Fixed by unwrapping via `getRawData()`; locked with a DTO-input
regression test that fails pre-fix.

## Notes

- This is the OBJECT-shaped sibling of the
  `fail-open-default-on-safety-flag-json-field` skill: both are silent
  fail-OPEN safety bypasses; this one is caused by the read mechanism
  (`(array)` cast), not a default value.
- Scope-wide for buyerkiosk-web: the `Payroll\DTOs`, `Core`, and many `Models`
  classes store state in private props. Grep for `(array)$` near
  `array_key_exists` / `isset(` / `['` to find other instances.
- The deeper process lesson (see `never_propose_merge_with_unchecked_e2e`):
  a unit test that mocks a collaborator with a simpler shape than production
  returns will green-light a dead code path. A live/integration smoke against
  the real producer is what surfaces it.
- Readonly/typed properties (PHP 8.1+) behave the same under `(array)` —
  visibility, not readonly-ness, drives the mangling.

## References

- PHP manual, Type Juggling / array casting: https://www.php.net/manual/en/language.types.array.php#language.types.array.casting
  (private members get the class name prepended; protected get `*`, both wrapped in NUL bytes).
- spec-050 commit `d6fce3aae` for the live fix + regression test.
