How to easily access private properties and methods in PHP original

by Freek Van der Herten – 7 minute read

Sometimes you need to access a private property or method on an object that isn't yours. Maybe you're writing a test and need to assert some internal state. Maybe you're building a package that needs to reach into another object's internals. Whatever the reason, PHP's visibility rules are standing in your way.

Our spatie/invade package provides a tiny invade function that lets you read, write, and call private members on any object.

You probably shouldn't reach for this package often. It's most useful in tests or when you're building a package that needs to integrate deeply with objects you don't control.

Let me walk you through how it works.

Using invade

Imagine you have a class with private members:

class MyClass
{
    private string $privateProperty = 'private value';

    private function privateMethod(): string
    {
        return 'private return value';
    }
}

If you try to access that private property from outside the class, PHP will stop you:

$myClass = new MyClass();

$myClass->privateProperty;
// Error: Cannot access private property MyClass::$privateProperty

With invade, you can get around that. Install the package via composer:

composer require spatie/invade

Now you can read that private property:

// returns 'private value'
invade($myClass)->privateProperty;

You can set it too:

invade($myClass)->privateProperty = 'changed value';

// returns 'changed value'
invade($myClass)->privateProperty;

And you can call private methods:

// returns 'private return value'
invade($myClass)->privateMethod();

The API is clean and reads well. But the interesting part is what happens under the hood. Before we look at the package code, there's a PHP rule you need to know about first.

How it works under the hood

Let me walk you through how the package works internally. We'll first look at the old approach using reflection, and then the current solution that uses closure binding.

The first version: reflection

In v1 of the package, we used PHP's Reflection API to access private members. Here's what the Invader class looked like:

class Invader
{
    public object $obj;
    public ReflectionClass $reflected;

    public function __construct(object $obj)
    {
        $this->obj = $obj;
        $this->reflected = new ReflectionClass($obj);
    }

    public function __get(string $name): mixed
    {
        $property = $this->reflected->getProperty($name);
        $property->setAccessible(true);

        return $property->getValue($this->obj);
    }
}

When you create an Invader, it wraps your object and creates a ReflectionClass for it. When you try to access a property like invade($myClass)->privateProperty, PHP triggers the __get magic method. It uses the reflection instance to find the property by name, calls setAccessible(true) on it, and then reads the value from the original object. The setAccessible(true) call tells PHP to skip the visibility check for that reflected property. Without it, trying to read a private property through reflection would throw an error, just like accessing it directly.

This worked fine, but it required creating a ReflectionClass instance and calling setAccessible(true) on every property or method you wanted to access. In v2, we replaced all of this with a much simpler approach using closures. To understand how, we first need to look at a lesser-known PHP visibility rule.

Private visibility is scoped to the class

In PHP, private visibility is scoped to the class, not to a specific object instance. Any code running inside a class can access the private properties and methods of any instance of that class.

Here's a concrete example:

class Wallet
{
    public function __construct(
        private int $balance
    ) {
    }

    public function hasMoreThan(Wallet $other): bool
    {
        // This works: we can read $other's private $balance
        // because we're inside the Wallet class scope
        return $this->balance > $other->balance;
    }
}

$mine = new Wallet(100);
$yours = new Wallet(50);

// returns true
$mine->hasMoreThan($yours);

Notice how hasMoreThan reads $other->balance directly, even though $balance is private. This compiles and runs without errors because the code is running inside the Wallet class. PHP doesn't care which instance the property belongs to. As long as you're in the right class scope, all private members of all instances of that class are accessible.

This is the foundation that makes v2 of the invade package work. If you can get your code to run inside the scope of the target class, you get access to its private members. PHP closures give us a way to do exactly that.

How closures can change their scope

PHP closures carry the scope of the class they were defined in. But the Closure::call() method lets you change that. It temporarily rebinds $this inside the closure to a different object, and it also changes the scope to the class of that object.

$readBalance = fn () => $this->balance;

$wallet = new Wallet(100);
// returns 100
$readBalance->call($wallet);

Even though $balance is private, this works. The ->call($wallet) method binds the closure to the $wallet object and puts it in the Wallet class scope. When PHP evaluates $this->balance, it sees that the code is running in the scope of Wallet, so it allows the access.

This is the entire trick that invade v2 is built on. Now let's look at the actual code.

The current Invader class

When you call invade($object), it returns an Invader instance that wraps your object. The current version of the Invader class is surprisingly small:

class Invader
{
    public function __construct(
        public object $obj
    ) {
    }

    public function __get(string $name): mixed
    {
        return (fn () => $this->{$name})->call($this->obj);
    }

    public function __set(string $name, mixed $value): void
    {
        (fn () => $this->{$name} = $value)->call($this->obj);
    }

    public function __call(string $name, array $params = []): mixed
    {
        return (fn () => $this->{$name}(...$params))->call($this->obj);
    }
}

That's the entire class. No reflection, no complex tricks. Just PHP magic methods and closures.

When you write invade($myClass)->privateProperty, the invade function creates a new Invader instance. PHP can't find privateProperty on the Invader class, so it triggers __get('privateProperty'). The __get method creates a short closure fn () => $this->{$name} and calls it with ->call($this->obj). As we just learned, this binds $this inside the closure to your original object and puts the closure in that object's class scope. PHP then evaluates $this->privateProperty inside the scope of MyClass, and the private access is allowed.

The __set method uses the same pattern, but assigns a value instead of reading one:

(fn () => $this->{$name} = $value)->call($this->obj);

The $value variable is captured from the enclosing scope of the __set method, so it's available inside the closure.

For calling private methods, __call follows the same approach:

return (fn () => $this->{$name}(...$params))->call($this->obj);

The closure calls the method by name, spreading the parameters. Since ->call() binds the closure to the target object, PHP sees this as a call from within the class itself, and the private method becomes accessible.

In closing

The invade package is a fun example of how PHP closures and scope binding can bypass visibility restrictions in a clean way. It's a small trick, but understanding why it works teaches you something interesting about how PHP handles class scope and closure binding.

The original idea for the invade function came from Caleb Porzio, who first introduced it as a helper in Livewire to replace a more verbose ObjectPrybar class. We liked the concept so much that we turned it into its own package.

Just remember: use it sparingly. It works great in tests or when you're building a package that needs deep integration with objects you don't control. In your regular project code, you probably don't need invade.

You can find the package on GitHub. This is one of the many packages we've created at Spatie. If you want to support our open source work, consider picking up one of our paid products.

Join 9,500+ smart developers

Get my monthly newsletter with what I learn from running Spatie, building Oh Dear, and maintaining 300+ open source packages. Practical takes on Laravel, PHP, and AI that you can actually use.

"Freek publishes a super resourceful and practical newsletter. A must for anyone in the Laravel space"

Joey Kudish — Shipping with AI as a teammate

No spam. Unsubscribe anytime. You can also follow me on X.

Found something interesting to share? Submit a link to the community section.