Table of Contents

PHP RFC: Asymmetric Visibility

Introduction

PHP has long had the ability to control the visibility of object properties -- public, private, or protected. However, that control is always the same for both get and set operations. That is, they are “symmetric.” This RFC proposes to allow properties to have separate (“asymmetric”) visibility, with separate visibility for read and write operations. The syntax is mostly borrowed from Swift.

Proposal

This RFC provides a new syntax for declaring the “set” operation visibility of an object property. Specifically:

class Foo
{
    public private(set) string $bar;
}

This code declares a property $bar that may be read from public scope but may only be modified from private scope. It may also be declared protected(set) to allow the property to be set from any protected scope (that is, child classes).

The behavior of the property is otherwise unchanged, aside from the restricted visibility.

Asymmetric visibility properties may also be used with constructor property promotion:

class Foo
{
    public function __construct(
        public private(set) string $bar,
    ) {}
}

In the interest of explicitness, if a set visibility is specified, a normal/get visibility must also be specified. Doing otherwise results in a compile error. That is, the following are legal:

public protected(set) string $foo;
protected private(set) string $bar;

but the following is NOT legal:

// Compile error
protected(set) string $foo;

References

While a reference to a property with restricted set visibility may still be obtained, it may only be obtained from a scope that would allow writing. Put another way, obtaining a reference to a property follows the set visibility, not the get visibility. Trying to obtain a reference to a property with a more restrictive scope will result in an error.

For example:

class Foo
{
    public protected(set) int $bar = 0;
 
    public function test() {
        // This is allowed, because it's private scope.
        $bar = &$this->bar;
        $bar++;
    }
}
 
class Baz extends Foo
{
    public function stuff() {
        // This is allowed, because it's protected scope.
        $bar = &$this->bar;
        $bar++;
    }
}
 
$foo = new Foo();
 
// This is fine, because the update via reference is 
// inside the method, thus private scope.
$foo->test();
 
// This is also fine.
$baz = new Baz();
$baz->stuff();
 
// Getting this reference is not allowed here, because this is public
// scope but the property is only settable from protected scope.
$bar = &$foo->bar;

Object properties

If the property is an object, the restricted visibility applies only to changing the object referenced by the property. It does not impact the object itself. That is consistent with the behavior of the readonly property.

Example:

class Bar
{
    public string $name = 'beep';
}
 
class Foo
{
    public private(set) Bar $bar;
}
 
$f = new Foo();
 
// This is allowed
$f->bar->name = 'boop';
 
// This is NOT allowed
$f->bar = new Bar();

Permitted visibility

The set visibility, if specified explicitly, MUST be strictly lesser than the main (get) visibility. That is, the set visibility may only be protected or private. If the main visibility is protected, set visibility may only be private. Any violation of this rule will result in a compile time error.

Inheritance

PHP already allows child classes to redeclare parent class properties, if and only if they have the same type and their visibility is the same or wider. That is, a protected string $foo can be overridden with public string $foo but not private string $foo. This RFC continues that rule, but independently for get and set operations.

That means, for instance, the following is legal:

class A {
    private string $foo;
}
class B extends A {
    protected private(set) string $foo;
}
 
class C extends B {
    public protected(set) string $foo;
}
 
class D extends C {
   public string $foo;
}

As in each child class, the get visibility is the same or wider than the parent, and the set visibility is the same or wider than the parent. Narrowing the visibility is not allowed, however.

class A {
    public string $foo;
}
 
class B extends A {
    // This is an error.
    public protected(set) string $foo;
}

Interaction with __set and __unset

In PHP 8.2, the behavior of __set has some subtleties to it due to readonly. In addition, readonly is actually two different modifiers in one: a write-once marker and an implicit private(set). That introduces a question as to which aspect of it is associated with its special behavior.

The following is (to the best of our ability to determine) the existing logic in PHP 8.2 for how __set behaves:

// When writing to a property on an object with __set:
if (property is NOT read-visible) {
  call __set
} else { // It is read-visible
  if (property is set-visible) {
    assign property
  } else { // The property is NOT set-visible
    if (property is unset) {
      call __set
    } else {

      if (property is readonly) {
        error
      } else {
        call __set
      }
    }
  }
}

In particular, readonly has an extra requirement that __set will only be called if the property has been explicitly unset(). By associating that conceptually with the write-once-ish part of readonly, we're able to avoid the need for that on properties that have only asymmetric visibility.

The net result is that properties with an explicit asymmetric visibility will trigger __set (if defined) if written to from a scope where they are not visible, regardless of whether they have been unset() or not. (Whether or not that requirement for readonly properties is reasonable is out of scope for this RFC.)

If __set() is not defined, the write will fail with an error regardless.

The logic for calling unset() externally (and thus triggering __unset()) is the same.

Relationship with readonly

The readonly flag, introduced in PHP 8.1, has an implicit private(set) behavior. While there are ways to recast the meaning of readonly such that it will combine with asymmetric visibility cleanly, there's no clear consensus on which of those ways is best, nor some of the edge cases they introduce. Therefore, for the time being, mixing readonly with explicit asymmetric visibility is not allowed.

This restriction can and should be relaxed in a later, dedicated RFC where those details can be hashed out more explicitly.

Typed properties

Asymmetric visibility is only compatible with properties that have an explicit type specified. This is mainly due to implementation complexity. However, as any property may now be typed mixed and defaulted to null, that is not a significant limitation.

Static properties

This functionality applies only to object properties. It does not apply to static properties. For various implementation reasons that is far harder, and also far less useful. It has therefore been omitted from this RFC.

Reflection

The ReflectionProperty object is given two new methods: isProtectedSet(): bool and isPrivateSet(): bool. Their meaning should be self-evident.

class Test
{
    public string $open;
    public protected(set) string $restricted;
}
 
$rClass = new ReflectionClass(Test::class);
 
$rOpen = $rClass->getProperty('open');
print $rOpen->isProtectedSet() ? 'Yep' : 'Nope'; // prints Nope
 
$rRestricted = $rClass->getProperty('open');
print $rRestricted->isProtectedSet() ? 'Yep' : 'Nope'; // prints Yep

Additionally, the two constants ReflectionProperty::IS_PROTECTED_SET and ReflectionProperty::IS_PRIVATE_SET are added. They are returned from ReflectionProperty::getModifiers(), analogous to the other visibility modifiers.

Modifying asymmetric properties via ReflectionProperty::setValue() is allowed, just as it is for protected or private properties, even outside of the classes scope.

Backward Incompatible Changes

None. This syntax would have been a parse error before.

Proposed PHP Version(s)

PHP 8.3

RFC Impact

Future Scope

This RFC is kept very simple. However, it does allow for future expansion.

Alternate operations

At this time, there are only two possible operations to scope: read and write. In concept, additional operations could be added with their own visibility controls. Possible examples include:

This RFC does NOT include any of the above examples; they are listed only to show that this syntax supports future expansion should a use be found.

Additional visibility

Should PHP ever adopt packages and package-level visibility, this syntax would be fully compatible with it. For example, public package(set) would be a natural syntax to use.

This RFC does NOT include any discussion of such expanded visibility definition, just notes that it in no way precludes such future developments.

readonly compatibility

As noted above, at this time asymmetric visibility cannot be combined with readonly properties. While the implementation of interlacing the two features is not difficult, there are some edge cases that need to be sorted out. For instance, it may require relaxing the “permitted visibility” rules in some cases. We felt that was best pushed to a separate RFC to minimize controversy on this RFC. The authors believe such interlacing can and should be done, just in a separate RFC.

Abbreviated form

It would be possible in the future to allow the get visibility to default to public if only a set visibility is specified. That may help avoid long property declarations if combined with readonly or other features. It has been omitted for now in the interest of explicitness, but could easily be reintroduced in the future.

Property accessors

Asymmetric visibility has been proposed before as a component of the Property Accessors RFC. That RFC models directly on C# syntax, which puts limited-operation visibility on the right of the property as part of the accessor definition.

This RFC uses a syntax borrowed from Swift, which provides similar functionality but with the visibility modifiers together on the left side of the property. The proposed syntax was slightly favored in a poll. It also allows asymmetric visibility to be proposed and discussed independently of the larger property accessor question.

While the authors do support (and intend to work on) property accessors generally, we feel it is better addressed separately from asymmetric visibility as the two are orthogonal considerations. Notably, this RFC does NOT preclude or limit the development of property accessors in the future.

Proposed Voting Choices

This is a simple yes-or-no vote to include this feature. 2/3 majority required to pass.

Include asymmetric visibility?
Real name Yes No
alec (alec)  
asgrim (asgrim)  
ashnazg (ashnazg)  
bmajdak (bmajdak)  
bwoebi (bwoebi)  
crell (crell)  
derick (derick)  
galvao (galvao)  
geekcom (geekcom)  
girgias (girgias)  
ilutov (ilutov)  
kalle (kalle)  
kelunik (kelunik)  
nicolasgrekas (nicolasgrekas)  
ocramius (ocramius)  
patrickallaert (patrickallaert)  
pierrick (pierrick)  
ramsey (ramsey)  
sebastian (sebastian)  
seld (seld)  
sergey (sergey)  
stas (stas)  
svpernova09 (svpernova09)  
thekid (thekid)  
theodorejb (theodorejb)  
timwolla (timwolla)  
Final result: 14 12
This poll has been closed.

If you vote no, the authors would appreciate feedback as to why (if you vote Yes, ignore this).

If voting no, why?
Real name Dislike feature itself Syntax details Other (specify below)
alec (alec)   
asgrim (asgrim)   
ashnazg (ashnazg)   
geekcom (geekcom)   
kalle (kalle)   
kelunik (kelunik)   
ocramius (ocramius)   
patrickallaert (patrickallaert)   
sebastian (sebastian)   
stas (stas)   
thekid (thekid)   
theodorejb (theodorejb)   
Final result: 6 4 2
This poll has been closed.

References

This syntax is borrowed directly from Swift's access control system.

Syntax decisions in this RFC are supported by a poll conducted in September 2022. The results were posted to the Internals list.