BreadcrumbHomeResourcesBlog Asymmetric Visibility In PHP 8.4: What It Means For PHP Teams November 21, 2024 Asymmetric Visibility in PHP 8.4: What It Means for PHP TeamsPHP DevelopmentBy Matthew Weier O’PhinneyPHP 8.4 releases on November 21, 2024, and it brings with it a wide range of exciting new features and functionalities for the PHP language. One of these new features is asymmetric visibility.Asymmetric visibility, combined with property hooks, will likely have an enormous impact on how PHP developers create data and message objects. In this deep-dive look at PHP 8.4, I unpack asymmetric visibility and provide step-by-step instructions for implementing this new feature.Table of ContentsWhat Is PHP Visibility?What Problem Does Asymmetric Visibility Solve?Introducing Asymmetric VisibilityAsymmetric Visibility: A Developer's GuideFinal ThoughtsTable of Contents1 - What Is PHP Visibility?2 - What Problem Does Asymmetric Visibility Solve?3 - Introducing Asymmetric Visibility4 - Asymmetric Visibility: A Developer's Guide5 - Final ThoughtsBack to topWhat Is PHP Visibility?PHP 5 introduced the concept of visibility with regards to class members. PHP visibility determines what can access a class constant, property, or method, and comes in three flavors:public visibility means that access is available anywhere an instance is consumed, both within the instance itself, as well as anywhere the instance is available.protected visibility means that access is available only within the instance, or within the instance of an extending class.private visibility means that access is available only within the instance, and is not extended to any extending classes.This deserves an example.enum LogLevel: string { case INFO = 'info'; case NOTICE = 'notice'; case WARNING = 'warning'; case ERROR = 'error'; case CRITICAL = 'critical'; } abstract class AbstractMessage { private const TEMPLATE = '[%s] (%s) %s'; abstract public function __toString(): string; protected function formatMessage(string $message, LogLevel $level): string { return sprintf( self::TEMPLATE, (new DateTimeImmutable())->format('c'), $level->value, $message ); } } class DefaultMessage extends AbstractMessage { public function __construct( private string $message, private LogLevel $level, ) { } public function __toString(): string { return $this->formatMessage($this->message, $this->level); } } class JsonMessage extends DefaultMessage { protected function formatMessage(string $message, LogLevel $level): string { return json_encode([ 'message' => $message, 'severity' => $level->value, 'ts' => (new DateTimeImmutable())->format('c'), ]); } } $message = new DefaultMessage('A basic message', LogLevel::INFO); $jsonMessage = new JsonMessage('A JSON message', LogLevel::NOTICE); echo $message; echo $jsonMessage;Let's break down the various interactions, and what is and isn't allowed with this model.The constant TEMPLATE is private. It can only be accessed from methods defined directly within AbstractMessage.The default formatMessage() method is protected, which means two things:It can only be called within an instance, or the instance of a class extension.It can be redefined within an extension. This also means that if the method is called within that instance, the redefined method will be used.Because formatMessage() is defined in AbstractMessage, it can access the TEMPLATE constant. Any extending class that calls that method will do so within the scope of AbstractMessage, and thus continue to work. As such, DefaultMessage::__toString() will result in a string that follows the template formatting from that constant.The properties $message and $level are defined as private. This means they can only be accessed directly within DefaultMessage; so why does JsonMessage work, then? Because it never accesses the properties itself: the __toString() method is defined within the context of DefaultMessage, and it is that method which accesses them.Since the constructor and __toString() methods of DefaultMessage are declared public, we can create instances and echo them from anywhere, which is what is done at the end of the script.Back to topWhat Problem Does Asymmetric Visibility Solve?With that background out of the way, what does the term asymmetric visibility mean? What problem does it solve?Let's go back to our previous example.Many developers and software architects will point out that this is not a message, as it has behavior. A message would just store data or state, and another object would format the message. This might look like:class Message { public string $message; public LogLevel $severity; public DateTimeInterface $timestamp; } interface Formatter { public function format(Message $message): string; }This looks fine until you consider that a Message could end up in an invalid state: there's no way to enforce a non-empty message, or that the severity or timestamp are set. Worse, if a Message is supposed to model a specific state, the fact that you can change the values after an instance is created is problematic.So we refactor. PHP 8.1 introduced the concept of a readonly property, which, coupled with constructor property promotion, gives us a succinct way to enforce that a value is present and cannot be overwritten:class Message { public function __construct( public readonly string $message, public readonly LogLevel $severity, public readonly DateTimeInterface $timestamp, ) { } }This solves the immutability problem, as well as ensures that we have values for each property. But how do we ensure the $message is non-empty?We could validate it in the constructor:<?php class Message { public readonly string $message; public function __construct( string $message, public readonly LogLevel $severity, public readonly DateTimeInterface $timestamp, ) { if (preg_match('/^\s*$/', $message)) { throw new InvalidArgumentException('message must be non-empty'); } $this->message = $message; } }However, this becomes a problem when we extend the class and override the constructor. The properties would all still exist, but our validation for $message would be lost.So this might be where we turn to property hooks in PHP 8.4. Let's refactor to ensure the $message property validates itself.class Message { public string $message { set (string $value) { if (preg_match('/^\s*$/', $value)) { throw new InvalidArgumentException('message must be non-empty'); } $this->message = $value; } } public function __construct( string $message, public readonly LogLevel $severity, public readonly DateTimeInterface $timestamp, ) { $this->message = $message; } }Now the $message value has validation... but we lost immutability.And this is the problem asymmetric visibility solves.Back to topIntroducing Asymmetric VisibilityThe asymmetric visibility RFC outlines a mechanism by which properties can have separate ("asymmetric") visibility, allowing different visibility for write versus read operations. The feature is often referred to as "aviz," and I will be periodically using that term throughout this post.The syntax is:{read visibility} {set visibility}(set) {propertyType} $name;Keep these notes in mind:If no read visibility is provided, public is assumed.Set (write) visibility is always followed by the string "(set)".The write visibility MUST be equal to or less visible than the read visibility.When combined with property hooks, the visibility applies to the corresponding get and set hooks.Asymmetric visibility requires that the property have a type declaration.Here are some examples:// Publicly accessible, writable internally only public private(set) string $message; // Equivalent private(set) string $message; // Publicly accessible, writable from instance and extending classes public protected(set) string $message; // Equivalent protected(set) string $message; // Accessible within instance and exensions, writable only within defining instance protected private(set) string $message;Let's rewrite our previous example Message class again.final class Message { public private(set) string $message { set (string $value) { if (preg_match('/^\s*$/', $value)) { throw new InvalidArgumentException('message must be non-empty'); } $this->message = $value; } } public function __construct( string $message, public readonly LogLevel $severity, public readonly DateTimeInterface $timestamp, ) { $this->message = $message; } }By adding exactly one thing — private(set) — to the $message property, we ensure that it cannot be overwritten except within the instance itself. By making the class itself final, and not defining any additional methods, we've made it fully immutable!Back to topAsymmetric Visibility: A Developer's GuideJust like with property hooks, there are many different interactions and contexts to keep in mind while implementing asymmetric visibility as you explore PHP 8.4. In this post, I'll cover:ReferencesArraysInheritance and InterfacesPHP readonly PropertiesProperty OverloadingReflectionReferencesBecause access to a reference means access to modify the reference, access for references follows the set visibility. In other words, if you define the following:class Access { public protected(set) int $counter = 0; }Then you CANNOT do this:$counter = &$accessInstance->counter;However, you CAN define the following method:class Access { public protected(set) int $counter = 0; public function increment(): void { $counter = &$accessInstance->counter; $counter++; } }As I noted in my post covering property hooks, I generally recommend against using references, as they enable action at a distance. That can be particularly problematic when dealing with instance properties.ArraysJust like with property hooks, arrays are a problematic backing value for asymmetric visibility, and the reason is because writing to an array property implicitly involves obtaining a reference first. As such, asymmetric visibility disallows appending or writing to an array property via a public context unless it's public-set. In other words, you CANNOT do this:class Collection { public private(set) array $items = []; } $collection = new Collection(); "$collection->items[] = new Item('value');"If you were to change the declaration to have public set, then the example would work:public public(set) array $items = [];However, this would be the same as just declaring the array public.The value asymmetric visibility provides for array values is to hide them and prevent changes to the array outside instance scope. As an example, building on the previous:class Collection { public private(set) array $items = []; public function insert(Item $item): void { // allowed, because $this->items[] = $item; } } $collection = new Collection(); $collection->insert(new Item('value')); $items = $collection->items;This is ostensibly better than using a bare array, as it enforces (a) that the values going into the instance are a specific type, and (b) that access to the property will always return a copy, preventing changes to the instance itself.Inheritance and InterfacesAsymmetric visibility follows the same rules as normal visibility when it comes to inheritance: implementing and extending classes must define their properties with the same or greater visibility.There are a couple of caveats,though:private(set) is considered equivalent to final, and means it cannot be redeclared in an extending class at all.Since interfaces define only the public API, this means that if a set operation is allowed for a property, it cannot be declared with asymmetric visibility.PHP readonly PropertiesThe readonly flag as provided starting in PHP 8.1 was essentially private(set) with a write-once semantic... but with a special case to allow overriding the property in a child class. With the introduction of asymmetric visibility, the internals developers had to evaluate this scenario, and made the decision that public and protected properties with the readonly flag would have an implicit protected(set) instead, reducing the number of special cases needed in the engine. Additionally, they decided that a private(set) readonly declaration is equivalent to final, just as it is with inheritance.Considering how easy it is to write a final class with public privated(set) properties, I suspect we'll see a lot less of the readonly flag going forwards. However, if you want to use it, you mostly don't need to think about how it interacts with aviz; it works as you likely already expected it to.Property OverloadingAs a reminder, PHP supports property overloading, using the "magic methods" __get(), __set(), __isset(), and __unset(). With these, you can define ways to provide public access to inaccessible or undefined properties.Asymmetric visibility largely allows you to avoid these methods altogether if you are using them in the first case, and my recommendation is to do just that, instead of mucking about with property overloading.If you must use property overloading, the main rule to know is that public protected(set) will NOT call __set(); this is considered an error, as public access is defined, but public set is disallowed. However, protected private(set) would trigger both __get() and __set(). In other words, the magic methods follow the get visibility: if it's public, then aviz rules apply; otherwise, magic methods will be called.ReflectionBecause there's additional "behavior" around visibility, there are now two additional methods in the ReflectionProperty class: isProtectedSet(): bool and isPrivateSet(): bool. Even if either of these returns true, ReflectionProperty::setValue() can always be used to change the value.Back to topFinal ThoughtsCoupled with property hooks, asymmetric visibility provides developers with powerful tools to write stateful data objects and messages. Prior to 8.1, developers needed to jump through a ton of hoops to lock down access to property members, and generally had to resort to instance methods for setting and obtaining access to instance state that had nothing to do with the public API. While the readonly flag introduced in PHP 8.1 gave a powerful tool for creating immutable structures, it didn't play well with inheritance, and did not address cases where internal state manipulation was deemed okay.Property hooks give powerful features for validating the correctness of a property value, as well as the ability to define and access "virtual" values; asymmetric visibility allows restricting when and how the value is changed. The combination enables a ton of functionality natively in the language that previously required some pretty poor workarounds.With the release of PHP 8.4, I'm looking forward to seeing what uses developers task these two features with, as well as how the language continues to develop.Planning an Upgrade to PHP 8.4?Zend is here to streamline your next PHP upgrade. Explore Long Term Support options, migration services, security solutions, and more.Discover Zend PHP LTS See Professional ServicesAdditional ResourcesOn-Demand Webinar - Developing Robust 12-Factor Web ApplicationsWhite Paper - The Costs of Building PHP In HouseBlog - What's New in PHP 8.4: Features, Changes, and DeprecationsBlog - A Guide to PHP 8.4 Property HooksBlog - A Developer's Guide to Building PHP APIsBlog - PHP Upgrades: How to Plan and Execute Your Next UpgradeBack to top
Matthew Weier O’Phinney Senior Product Manager, OpenLogic and Zend by Perforce Matthew began developing on Zend Framework (ZF) before its first public release, and led the project for Zend from 2009 through 2019. He is a founding member of the PHP Framework Interop Group (PHP-FIG), which creates and promotes standards for the PHP ecosystem — and is serving his second elected term on the PHP-FIG Core Committee.