sebastian/peek-and-poke
sebastian/peek-and-poke is a small PHP utility that uses reflection to peek at and poke into objects. It lets you read and write private and protected properties, making it handy for tests that need to inspect or set up object state without changing production code.
sebastian/peek-and-poke is a ~150-line micro-library that wraps an object and exposes its non-public properties and methods through PHP's magic methods. For a Laravel developer it is almost exclusively a testing tool: it lets you assert on and manipulate private state without rewriting production code to be "testable."
Install it as a dev dependency only:
composer require --dev sebastian/peek-and-poke
Before you commit to it, check two things:
NOASSERTION). There will be no PHP 8.x compatibility fixes, no security patches, and no support. Treat it as a temporary convenience, not a foundation. If your composer.json already pins a modern PHP version, run composer require and immediately run your test suite to confirm it autoloads and reflects correctly.SebastianBergmann\PeekAndPoke\Proxy). If your project uses an old PHPUnit, Composer may resolve a version you don't expect. composer show sebastian/peek-and-poke should show exactly one version.Where to look first: the single source file, src/Proxy.php. That's the whole API. There are no config files, service providers, or facades — it is not a Laravel package in that sense, it's a plain PHP utility you new up inside a test.
First use case — inspecting a private collaborator on a service:
use SebastianBergmann\PeekAndPoke\Proxy;
final class InvoiceCalculatorTest extends TestCase
{
public function test_uses_the_injected_tax_resolver(): void
{
$calculator = $this->app->make(InvoiceCalculator::class);
$proxy = new Proxy($calculator);
$this->assertInstanceOf(TaxResolver::class, $proxy->taxResolver);
}
}
The mental model is simple: new Proxy($obj) returns something whose __get, __set, __isset, __unset, and __call all resolve against $obj via Reflection, bypassing visibility.
Because you'll wrap objects constantly, put a tiny helper on your TestCase (or a trait) so tests stay readable and you keep a single place to swap the implementation later if you migrate off the archived package:
trait PeeksAndPokes
{
protected function peek(object $object): Proxy
{
return new Proxy($object);
}
}
Then $this->peek($service)->somePrivateValue. When you eventually replace it with ReflectionProperty or Closure::bind, you change one method.
The highest-value pattern in a Laravel app: call the public entry point, then verify the private bookkeeping.
$command = new SendDailyDigest();
(new Proxy($command))->handle($this->app->make(DigestService::class));
$this->assertTrue((new Proxy($command))->hasRun);
$this->assertSame(42, (new Proxy($command))->processedCount);
This is the pattern that lets you keep private on implementation details you don't want to expose as public API just for tests.
When a class news up or holds its dependency privately and you cannot (or will not) refactor to constructor injection right now:
$service = new ReportBuilder();
$proxy = new Proxy($service);
$proxy->exporter = Mockery::mock(Exporter::class)
->shouldReceive('export')->once()->andReturn(true);
$this->assertTrue($service->build());
Use this as a pressure valve, not a habit. In Laravel the better answer is almost always $this->app->instance(Exporter::class, $mock) against constructor injection — reach for the proxy only for legacy classes or framework internals you can't rebind.
Singletons and memoized private arrays are a classic source of order-dependent test failures. The proxy gives you a surgical reset:
$proxy = new Proxy($this->app->make(GeoLookup::class));
$proxy->cache = [];
Prefer this over rebuilding the container when the cost of a fresh instance is high.
$proxy = new Proxy($normalizer);
$this->assertSame('ACME-001', $proxy->normalizeSku(' acme 001 '));
This is genuinely useful when a private method has ten branches and the public wrapper only exposes two of them. Keep the number of such tests small — they are the most refactor-fragile tests you can write.
Occasionally you want to assert on something Laravel keeps private, e.g. the recorded state inside a Illuminate\Http\Client\Factory fake, the queued connections on a PendingDispatch, or the resolved handlers on a console command. The proxy gives you read access without ReflectionProperty boilerplate. Keep these assertions close to the integration boundary and don't build your test suite on framework private APIs — they move between minor versions.
php artisan tinker is fine for public API, but when you need to inspect a private field during an incident:
$p = new \SebastianBergmann\PeekAndPoke\Proxy($suspiciousService);
dump($p->internalState, $p->retryCount);
Don't leave this in application code; it's a debugging scalpel, not a production instrument.
Arrays are read-by-value — indirect modification silently fails. __get returns a copy for array properties, so:
$proxy->items['new'] = 'value'; // "Indirect modification of overloaded property has no effect"
Always read, mutate, write back:
$items = $proxy->items;
$items['new'] = 'value';
$proxy->items = $items;
Object properties are handles, so $proxy->someObject->foo = 'bar' works as expected.
No by-reference arguments or returns. __call cannot pass arguments by reference, so any private method with a &$param signature will misbehave (typically the by-ref variable stays unchanged rather than erroring loudly). This bites on recursive collectors and accumulator-style helpers.
Private members of parent classes are not found. ReflectionClass::getMethod()/getProperty() return the current class's private members but not a parent's private ones. If the value lives one level up the hierarchy, the proxy throws. Walk the hierarchy with ReflectionClass::getParentClass() yourself, or use Closure::bind($fn, null, ParentClass::class).
Uninitialized typed properties throw. Reading a private ?Foo $bar; before it's assigned raises Error: Typed property must not be accessed before initialization — the same as direct access. Guard with (new ReflectionProperty(...))->isInitialized($obj) or wrap in a try/catch, and avoid asserting on properties that are legitimately nullable-but-unset.
Read-only and readonly-property limits apply. PHP 8.1+ readonly properties cannot be written from the proxy's calling scope; PHP 8.2+ rejects writes to already-initialized readonly properties entirely. If you need to seed a readonly property, that's a design signal — inject the value instead.
Unset/uninitialized distinctions. isset($proxy->prop) maps to __isset, which respects visibility but also returns false for a set-but-null property (matching PHP's normal isset semantics). Use $proxy->prop === null or reflection if you need to distinguish "never set" from "set to null."
Property named object is shadowed. The wrapper stores the target in a private $object; __get('object') will look through to the wrapped object rather than return the target itself, and some collisions around internal names are possible. Extremely unlikely in practice, but don't name test fixtures with properties that collide with the proxy's internals.
Static members are out of scope. The constructor takes an object, so public static $foo needs plain ReflectionClass::getStaticPropertyValue().
No IDE or static-analysis support. $proxy->whatever is invisible to PHPStan/Psalm and autocomplete. Add @var docblocks on the property you read, or a /** @var SomeType $value */ inline annotation:
/** @var TaxResolver $resolver */
$resolver = $proxy->taxResolver;
Without this, level-5 PHPStan runs will report Access to an undefined property SebastianBergmann\PeekAndPoke\Proxy::$taxResolver.
The class is final — no extension point. You cannot subclass Proxy to add caching or logging. If you need that, write your own thin wrapper or a ReflectionProperty-based helper; the package's only extension surface is composition.
Debugging failures. The two errors you'll actually see:
ReflectionException: Property X does not exist — typo, or the member is on a parent class (see above).Error: Cannot access private property ... — this means the magic method wasn't triggered, usually because you're accessing the member via a typed property or a self::/$this-> path, not through the proxy. Re-check you're operating on the Proxy instance, not the raw object.Alternatives worth considering before you adopt it. For a one-off, Closure::bind(function () { return $this->secret; }, $object, $object)() needs no dependency at all. For structural access, ReflectionProperty::setAccessible(true) (no-op since PHP 8.1) is more verbose but permanent and supported. For collaborators, Laravel's container binding ($this->app->instance(), $this->mock(), $this->fake()) is nearly always the right answer. The proxy's only real advantage is ergonomics: $proxy->name beats three lines of reflection every time.
Pin it and fence it in. If you keep it, add it to require-dev, keep it out of any published package's require, and leave a TODO on the trait helper so a future refactor to ReflectionProperty is a single-file change. Given the archived repo, no license metadata, and zero dependents, treat any production exposure as a defect.
How can I help you explore Laravel packages today?