sebastian/object-reflector
sebastian/object-reflector lets you reflect an object's properties, including inherited, private, and protected ones. Install it via Composer to inspect and access object state that PHP's default reflection utilities don't easily expose.
Install as a dev-only dependency — this belongs nowhere near a production code path:
composer require --dev sebastian/object-reflector
There is exactly one class (SebastianBergmann\ObjectReflector\ObjectReflector) and one method (getProperties(object $object): array). The whole package is roughly forty lines, so the README is enough; if you want to know precisely what it does, read the source instead of the docs.
use SebastianBergmann\ObjectReflector\ObjectReflector;
$props = (new ObjectReflector())->getProperties($order);
// ['id' => 42, 'App\Domain\Order::status' => 'confirmed', ...]
Your first use case: an assertion on state you deliberately keep private. A domain service, a value object, or a third-party class where adding a getter just for a test would be worse than the test itself:
public function test_order_is_confirmed_after_checkout(): void
{
$order = $this->checkoutService->place($this->cart);
$state = (new ObjectReflector())->getProperties($order);
$this->assertSame(OrderStatus::Confirmed, $state['status']);
}
Before reaching for it, be honest about what it buys you. Native ReflectionClass::getProperties() already returns private and protected properties from the whole inheritance chain. The package's real value is the convenience: a flat name => value map in one call, with declaring-class qualification for inherited properties. If you only ever need one property, (fn () => $this->foo)->call($obj) is a one-liner with no dependency at all. And if you just want to look at private state, dd() / dump() already show it — Symfony VarDumper renders private and protected properties fine. This package is for programmatic access.
Composer resolves the right major for your runtime (v5 targets PHP 8.3, v6 requires 8.4), so on Laravel 11/12 you'll typically land on v4/v5. Don't hardcode a major in composer.json if a PHP upgrade is on the roadmap.
Centralise it in a test helper. The class is final, so wrap it rather than sprinkling new ObjectReflector() across a hundred tests. You get one place to handle inheritance keys, missing properties, and uninitialised values:
// tests/Support/InspectsPrivateState.php
trait InspectsPrivateState
{
protected function privateProperty(object $object, string $property): mixed
{
$all = (new ObjectReflector())->getProperties($object);
foreach ($all as $key => $value) {
if ($key === $property || str_ends_with($key, '::' . $property)) {
return $value;
}
}
throw new InvalidArgumentException(
sprintf('%s has no property [%s]', $object::class, $property)
);
}
}
Assert queue/job payload integrity. Jobs hold serialised state you rarely expose:
$job = new SyncInventory($sku, $qty);
$props = (new ObjectReflector())->getProperties($job);
$this->assertSame('SKU-1', $props['sku']);
Snapshot for regression tests. When a bug is fixed, lock the internal shape in place:
$this->assertSame(
['items' => 3, 'total' => 5999, 'App\Domain\Cart::discount' => null],
(new ObjectReflector())->getProperties($cart)
);
Careful with this one — it breaks on every refactor. Use it for one or two critical objects, not as a default pattern.
Debug helper for the whole suite. Register once in tests/Pest.php or a base TestCase:
function dump_private(object $object): void
{
dump((new ObjectReflector())->getProperties($object));
}
Inspect legacy/vendor objects. The strongest legitimate case: a third-party or pre-refactor class where the internal state matters and the alternative is forking the dependency.
Pest expectations. Wrap it as a custom expectation so it reads naturally:
expect($order)->toHavePrivateProperty('status', OrderStatus::Confirmed);
Isolate with an arch test. If you also use Pest's or PHPStan's architecture rules, add a rule that sebastian/object-reflector may only be referenced from tests/ and your helper trait. This is the only reliable guard against it leaking into src/.
App\Models\Model::id, not id. Naive $props['id'] lookups silently fail on any subclassed object. Always match with the str_ends_with($key, '::' . $name) fallback shown above.ReflectionClass directly — the package skips $property->isStatic().ReflectionProperty::getValue() throws Error: Typed property ... must not be accessed before initialization. In Laravel this bites on lazily-populated models and DTOs with nullable-promoted properties. Guard inside your helper with $property->isInitialized($object) if you hit it — you cannot fix it from the caller.App\Models\User object, not its attributes. Recurse deliberately, and depth-limit it.$attributes include hashed passwords, tokens, and API keys. Never pipe raw output into CI logs or an exception message. Sanitise with a key denylist before dumping.json_encode() returns false on closures; print_r() on a resource is useless. Check is_scalar() before stringifying, and consider get_debug_type() for the rest.ReflectionProperty::setValue() yourself and accept that you're writing a smell, not a test.final. No subclassing, no extension points. Your wrapper trait is the extension point.require-dev alone won't stop someone importing it in a service. Add the arch/PHPStan rule in the same PR that introduces the dependency — retrofitting it later never happens.composer update suddenly wants a major bump, that's a PHP requirement signal, not a bug.How can I help you explore Laravel packages today?