spatie/invade
Spatie's invade function lets you read, write, and call private properties and methods of any PHP object, including static ones, through a simple proxy—handy for testing or working with legacy code.
Install (test-time dependency, usually):
composer require --dev spatie/invade
There is no service provider, no config file, no publishable assets, no facade. Composer autoloads a single global invade() function (guarded by function_exists, so it won't explode if you ever define your own). If you only poke at objects from tests, Tinker, or a local-only debug command, keep it in require-dev; if you ship a debug route, move it to require.
Where to look first:
src/ — an invade() function plus an instance Invader (property read/write, method call via magic __get/__set/__call) and a StaticInvader (get() / set() / method()->call()).CHANGELOG — v2.0.0 replaced the Reflection implementation with bound closures, and v2.1.0 added static support. Anything below ^2.1 won't have invade(Class::class).First use case: you have a Laravel service with no getter, and a test that needs to prove the side effect happened.
final class ImportReport
{
private array $failures = []; // no accessor on purpose
public function run(array $rows): void { /* ... */ }
}
it('records failures without throwing', function () {
$report = new ImportReport();
$report->run([['id' => null], ['id' => 5]]);
expect(invade($report)->failures)->toHaveCount(1);
});
That's the sweet spot: one line, no ReflectionClass, no setAccessible(true) boilerplate, and the intent reads clearly in a diff.
1. Testing state, not internals, as the default. Use invade() for assertions on state that has no public surface, and keep using real public APIs to arrange state. Reaching in to set up and then asserting via the public API is the more robust order — it fails for the right reason when someone refactors.
2. Seeding a hard-to-reach state instead of mocking the world. Classic case: a client object whose private $attempts counter or private $token you can't set. One assignment beats a five-line Mockery chain and keeps the real code path under test.
$client = new ApiClient($config);
invade($client)->token = 'expired-token'; // force the refresh branch
expect($client->get('/me'))->toBeArray();
3. Calling a private method to unit-test extracted logic. Legal, occasionally useful when a big public method fans out into a private one, but treat it as a smell alarm: if you're calling invade($x)->parseRow(...) in many tests, parseRow wants to be its own class with a public method. Invade makes that pressure invisible, so review it in PRs.
4. Static state (2.1+). Registries, singletons, and static caches are common in older Laravel code:
invade(CacheRegistry::class)->get('drivers');
invade(CacheRegistry::class)->set('drivers', []);
invade(CacheRegistry::class)->method('resolveDriver')->call('redis');
Note the asymmetry: statics take a class-string and use get()/set()/method()->call(); instances take an object and use property syntax. Don't expect the instance style to work on a class-string or vice versa.
5. Laravel framework / package introspection. The highest-value everyday use is post-upgrade smoke tests against framework or third-party internals you don't control: assert a Spatie package still populates its private cache, that a queued job's private payload survived serialization, that Livewire-style components still hold the internal state you depend on. This is the "canary" pattern — one invade assertion catches a silent breaking change that a green test suite would otherwise miss.
6. Tinker / debug commands. In php artisan tinker, invade($container->make(Foo::class))->cache is faster than writing (fn () => $this->cache)->call($obj). If you do this often, wrap it: a tiny App\Support\Peek helper keeps invade out of your domain code and greppable in one place.
7. Tidy assertion helper. Avoid sprinkling raw property syntax through a suite:
function privateState(object $object, string $property): mixed
{
return invade($object)->{$property};
}
expect(privateState($report, 'failures'))->toHaveCount(1);
8. Always reset static state. Any test that calls invade(SomeClass::class)->set(...) must restore the original in afterEach/tearDown. Static mutations leak across tests in the same PHP process, producing the worst kind of order-dependent failures.
private property declared in a base class isn't accessible from a subclass instance. Symptom: a confusing "cannot access private property" error even though the property clearly exists one extends up. Workaround is a small ReflectionProperty against the declaring class — at which point, consider whether the design wants a getter.string-typed property with an int throws TypeError; reading a typed property that was never initialized throws Error: must not be accessed before initialization; writing a PHP 8.1 readonly property throws. None of these are invade-specific, but they surface as raw PHP errors rather than friendly test failures, so wrap or assert deliberately.invade($o)->items[] = 'x'; silently does nothing useful — the array is a copy. Read it, mutate it, assign it back: $items = invade($o)->items; $items[] = 'x'; invade($o)->items = $items;. Objects inside the array are still handles, so mutating those works.__get exists. On a class with a magic getter, invade($o)->itemz won't error — it'll return whatever __get decides (often null). A typo then produces an assert-null false negative. Keep property names in one place, or assert property_exists() in a helper.invade($instance)->get('prop') fails with "call to undefined method" because get() lives on the static invader. Conversely, an object passed where a class-string is expected is rejected (2.1.1 tightened this validation) — the error message is clear, but only if you know the two APIs are separate.invade() returns a proxy object, so var_dump(invade($o)) shows the wrapper, not the target. Debug the target directly; for uninitialized-property questions use (new ReflectionProperty($o, 'x'))->isInitialized($o).extra.phpstan.includes, which is only picked up automatically if phpstan/extension-installer is present — usual with Larastan, but if you get "Access to private property" errors in CI, add the include manually to phpstan.neon rather than littering @phpstan-ignore.app()->isLocal()) so it can't be used to bypass invariants in production.Closure::bind(fn () => $this->x, $object, $object::class) — and keep the wrapper tested once instead of re-deriving it per test.How can I help you explore Laravel packages today?