PHP 8.5 Asymmetric Visibility for Static Properties

PHP 8.5 brings public private(set) static to PHP — read class-level state from anywhere, gate writes to the declaring scope. Here's the syntax and gotchas.

Steven Richardson
Steven Richardson
· 7 min read

PHP 8.4 shipped asymmetric visibility for instance properties and quietly left static properties out of the box. Anyone with a static $instances = 0 counter or a class-level registry stayed on the same dance: declare it private, wrap it in a getCount() method, hope nobody from another package writes to it directly. PHP 8.5 finishes the job — public private(set) static is now valid syntax, and the entire pattern collapses to one line.

How asymmetric static properties work#

Asymmetric visibility separates read access from write access on a single property. The "outer" visibility controls who can read; the parenthesised modifier controls who can write. PHP 8.5 simply removes the rule that the property has to be an instance property. Anything you could do with an object property in 8.4, you can now do with a static one.

final class JobRegistry
{
    public private(set) static int $registered = 0;

    public static function register(string $job): void
    {
        // ...register logic...
        self::$registered++;
    }
}

echo JobRegistry::$registered;       // 0 — fine, public read
JobRegistry::$registered = 99;       // Fatal error — write is private

Two things to note. First, the property must have a type declaration; the engine refuses to compile otherwise. Second, you still pick the read modifier first — public private(set) means "public read, private write". Reversing the order is invalid syntax, not a stylistic choice.

The same modifiers from 8.4 carry over — protected(set), private(set), and the implicit forms when the read visibility is omitted. The new bit is the static keyword sliding in next to them. If you've used PHP 8.4 property hooks in Laravel models, the mental model is identical, just hoisted up to the class scope.

Before and after: the singleton counter#

Here's the pattern almost every framework or service class has shipped at some point — a static counter, defensively wrapped:

// PHP 8.4 and earlier
final class Connection
{
    private static int $opened = 0;

    public static function open(): self
    {
        self::$opened++;
        return new self();
    }

    public static function opened(): int
    {
        return self::$opened;
    }
}

The opened() getter exists for one reason: to stop an external caller doing Connection::$opened = 0; and corrupting the count. PHP 8.5 lets you delete it.

// PHP 8.5
final class Connection
{
    public private(set) static int $opened = 0;

    public static function open(): self
    {
        self::$opened++;
        return new self();
    }
}

echo Connection::$opened;    // Reads work
Connection::$opened = 0;     // Fatal — write is private

Same guarantee, one fewer method, no behavioural difference at the call sites that were already reading via ::opened(). Static analysers like PHPStan have shipped support since the RFC was accepted, so the violation surfaces at level 5 and above rather than waiting for runtime.

The pattern scales cleanly to registries too. A class-level array of registered handlers — common in event dispatcher and middleware code — drops the same boilerplate:

final class HandlerRegistry
{
    public private(set) static array $handlers = [];

    public static function register(string $event, callable $handler): void
    {
        self::$handlers[$event][] = $handler;
    }
}

// External code can iterate freely
foreach (HandlerRegistry::$handlers as $event => $list) { /* ... */ }

// But cannot reassign or clear the registry
HandlerRegistry::$handlers = [];   // Fatal

Combining with readonly#

Readonly properties and asymmetric visibility look like they overlap but solve different problems. readonly means write-once-after-declaration, anywhere in the class hierarchy. Asymmetric visibility says "writes are allowed indefinitely, but only inside this scope". Static properties can't be readonly directly — that restriction was lifted for instance properties in 8.4 but not statics — so 8.5's asymmetric form is currently the only built-in way to lock down a static property without a manual getter.

Inside a regular readonly class, instance properties default to protected(set) so subclasses can still initialise them in their own constructors. Static properties opted out of readonly entirely, which is exactly why this 8.5 addition matters: it gives static properties the same write-protection guarantees the rest of the class already has. If you're building immutable value objects with readonly classes in Laravel, the natural next step is reaching for private(set) static on any class-level caches or registries those value objects need.

For inheritance, you have three useful flavours:

class BaseHandler
{
    // Subclasses can write
    public protected(set) static int $dispatched = 0;

    // Only this exact class can write
    public private(set) static array $cache = [];
}

class CustomHandler extends BaseHandler
{
    public static function dispatch(): void
    {
        self::$dispatched++;     // OK — protected(set) allows subclass writes
        // self::$cache = [];    // Fatal — private(set) is parent-only
    }
}

Pick protected(set) when subclasses legitimately need to mutate the property, and private(set) when the parent class is the sole authority. The default — when you write public(set) or omit the modifier — keeps the old 8.0 behaviour intact, so existing code is unaffected by the upgrade.

Reflection and DI containers#

Reflection is the part that catches frameworks off-guard whenever the language adds a visibility wrinkle. PHP 8.5 keeps the existing ReflectionProperty::isPublic() / isProtected() / isPrivate() reporting the read visibility, and adds isPrivateSet() and isProtectedSet() to expose the write visibility separately.

$rp = new ReflectionProperty(JobRegistry::class, 'registered');

$rp->isPublic();      // true  — read visibility
$rp->isPrivateSet();  // true  — write visibility
$rp->isProtectedSet();// false
$rp->isStatic();      // true

Container resolution and serialisation work without changes provided your framework already handles asymmetric visibility for instance properties. Laravel's container and the symfony/serializer family picked up support during the 8.4 cycle, so the static extension lands cleanly. Hydrators and custom serialisers written in-house are the most likely place to hit a regression — anything that uses setAccessible(true) and writes through reflection should keep working, but anything that reads visibility metadata to decide whether to skip a property may need to look at the new methods.

If you maintain static analysers, custom Pint rules, or Rector configs, audit those for assumptions about static properties being uniform-visibility. The same goes for IDE plugins that surface "this property is public, are you sure?" warnings.

Gotchas and edge cases#

Type declarations are mandatory. This trips people up coming from older code. public private(set) static $counter = 0; is a syntax error — you need public private(set) static int $counter = 0;. The type system relies on knowing the storage shape to enforce visibility correctly.

No var keyword combinations. The pre-modern var keyword still exists for historical reasons but does not combine with asymmetric visibility. Stick to explicit modifiers.

Trait conflicts behave like instance properties. If two traits declare the same static property with different visibility, the conflict resolution is the same as for any other trait collision — you have to alias it explicitly. The modifier doesn't get a special pass.

Late static binding still resolves to the declaring class for visibility. static::$counter = 1 from a subclass is rejected when the parent declared private(set), even with static:: resolution. The check is on declaration scope, not resolution scope.

Composer auto-loaders and PHP versions. If you're shipping a library, gate the syntax behind a php >= 8.5 constraint in composer.json. Any consumer on 8.4 will get a parse error during autoload, not a graceful fallback. Run PHP 8.5 deprecations cheat sheet before bumping the constraint to make sure the rest of your code is clean too.

Tooling support. PHPStan supports the syntax from 1.12+; Psalm shipped support in 6.x; Pint and php-cs-fixer handle the formatting in their respective 8.5-aware releases. Ensure your CI is running a tooling version that knows about static asymmetric visibility before merging — older versions will silently miss the violations.

Wrapping Up#

Asymmetric visibility for static properties is one of those quiet additions that only becomes obvious once you delete the third defensive getCount() method from a registry class. It's a small win per class and a big win across a codebase. Add it to your "things to refactor" list when you bump to 8.5, especially in framework or library code where boilerplate getters live longest.

If you're working through other 8.5 adoption decisions, the PHP 8.5 clone with syntax for readonly objects is the natural companion feature — together they finish the immutability story PHP started in 8.1. The PHP 8.5 pipe operator is worth a look once your visibility refactor is in.

FAQ#

What is asymmetric visibility for static properties in PHP 8.5?

Asymmetric visibility lets a static property declare different visibility for reads and writes. PHP 8.5 extends the feature that landed for instance properties in PHP 8.4, so syntax like public private(set) static int $counter = 0 is now legal. The property is publicly readable from anywhere but only writable from inside the declaring class.

How do I write public private(set) static in PHP 8.5?

Declare the property with a type, the read visibility, the parenthesised write visibility, and the static keyword in that order. For example: public private(set) static int $counter = 0;. The type declaration is mandatory — without it, the engine throws a compile-time error. Read access works from anywhere, but writes are only allowed inside the declaring class scope.

Can I use asymmetric visibility on inherited static properties?

Yes, with the same rules as instance properties. Use protected(set) to allow subclasses to write the property, or private(set) to lock writes to the declaring class only. Late static binding still respects the declaration scope, so static::$prop = $value from a subclass is rejected when the parent declared private(set). The read visibility is independent and follows normal inheritance rules.

Does asymmetric visibility work alongside readonly?

Static properties cannot be readonly directly — that restriction stayed in place when readonly classes shipped — so asymmetric visibility is currently the cleanest write-protection mechanism for class-level state. For instance properties, readonly classes default to protected(set) semantics in 8.5, so the two features cooperate. Mixing readonly and asymmetric visibility on the same instance property is allowed when their constraints don't contradict, but the readonly write-once rule still wins.

When should I prefer asymmetric visibility over a getter method?

Prefer asymmetric visibility when the property's only job is to expose readable state and you'd otherwise add a one-line getX() method. The new syntax removes the getter boilerplate and keeps reflection-based tools accurate. Keep an explicit getter when you need to transform the value, log access, or compute it lazily — those are the cases where the method body is doing real work, not just returning the field.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.