rfc:property-hooks

PHP RFC: Property hooks

Introduction

Developers often use methods to wrap and guard access to object properties. There are several highly common patterns for such logic, which in practice may be verbose to implement repeatedly. Alternatively, developers may use __get and __set to intercept reads and writes generically, but that is a sledge-hammer approach that intercepts all undefined (and some defined) properties unconditionally. Property hooks provide a more targeted, purpose-built tool for common property interactions.

The combination of this RFC and the Asymmetric Visibility RFC effectively replicate and replace the previous Property Accessors RFC. Much of the implementation is derived from Nikita's original work on that RFC.

The design and syntax below is most similar to Kotlin, although it also draws influence from C# and Swift. Python and Javascript have similar features via a different syntax, although that syntax would not be viable for PHP. Ruby treats properties and methods as nearly the same, so achieves this functionality as a side effect. In short, “property accessors” are a very common feature in major, mainstream programming languages.

A primary use case for hooks is actually to not use them, but retain the ability to do so in the future, should it become necessary. In particular, developers often implement getFoo/setFoo methods on a property not because they are necessary, but because they might become necessary in a hypothetical future, and changing from a property to a method at that point becomes an API change.

By allowing most common getFoo/setFoo patterns to be attached to properties directly, such behavior can be added to a property later without an API change and without the extra boilerplate of two mostly-meaningless methods for every property, “just in case.”

Methods that are not just variations on getFoo/setFoo behavior, of course, are still valuable in their own right.

Consider the following class declaration, which might have been considered idiomatic prior to PHP 7.4:

class User 
{
    private $name;
 
    public function __construct(string $name) {
        $this->name = $name;
    }
 
    public function getName(): string {
        return $this->name;
    }
 
    public function setName(string $name): void {
        $this->name = $name;
    }
}

As of PHP 8.3, if type enforcement is the only need, that can be abbreviated all the way down to:

class User 
{
    public function __construct(public string $name) {}
}

That is much nicer, but comes at a cost: If we later want to add additional behavior (such as validation or pre-processing), there's nowhere to do so. That currently leaves two options:

  1. Re-add getName() and setName() methods, making the property private or protected. This would be an API break.
  2. Use __get and __set. As shown below, this is verbose, ugly, error prone, and breaks static analysis tools.
class User 
{
    private string $_name;
 
    public function __construct(string $name) {
        $this->_name = $name;
    }
 
    public function __get(string $propName): mixed {
        return match ($propName) {
            'name' => $this->_name,
            default => throw new Error("Attempt to read undefined property $propName"),
        };
    }
 
    public function __set(string $propName, $value): void {
        switch ($propName) {
            case 'name':
                if (!is_string($value)) {
                    throw new TypeError("Name must be a string");
                }
                if (strlen($value) === 0) {
                    throw new ValueError("Name must be non-empty");
                }
                $this->_name = $value;
                break;
            default:
                throw new Error("Attempt to write undefined property $propName");
        }
    }
 
    public function __isset(string $propName): bool {
        return $propName === 'name';
    }
}

Property hooks allow developers to introduce additional behavior in a way that is specific to a single property while respecting all other existing aspects of PHP and its tooling.

class User 
{
    public string $name {
        set {
            if (strlen($value) === 0) {
                throw new ValueError("Name must be non-empty");
            }
            $field = $value;
        }
    }
 
    public function __construct(string $name) {
        $this->name = $name;
    }
}

This code introduces a new non-empty requirement, but does not change the outward syntax of reading or writing to $name, does not hinder static analysis, and does not fold multiple properties into a single hard-to-follow method.

Similarly, using methods may also impose an extra syntax burden on callers in “read and update” situations. For example:

class Foo
{
    private int $runs = 0;
 
    public function getRuns(): int { return $this->runs; }
 
    public function setRuns(int $runs): void
    {
      if ($runs <= 0) throw new Exception();
      $this->runs = $runs;
    }
}
 
$f = new Foo();
 
$f->setRuns($f->getRuns() + 1);

With property hooks, this can be simplified to:

class Foo
{
    private int $runs = 0 {
        set {
            if ($value <= 0) throw new Exception();
            $field = $value;
        }
    }
}
 
$f = new Foo();
 
$f->runs++;

Which is much more ergonomic from the user's point of view. (A incrementRuns() method would also work in this case, but would only support the one single use case of incrementing, not general read and write.)

Proposal Summary

This RFC introduces two “hooks” to override the default “get” and “set” behavior of a property. Although not included in this initial version, the design includes the ability to support more hooks in the future. (See the Future Scope section below.) Taken together, they allow for a majority of common reasons to add “just in case” methods to a property to be implemented without methods, leading to shorter code and more flexibility to improve the code without a hard API break.

There are two syntax variants supported, a full and a short, similar to closures. The example below shows both. (See the “Abbreviated Syntax” section below.)

class User implements Named
{
    private bool $isModified = false;
 
    public function __construct(private string $first, private string $last) {}
 
    public string $fullName {
        // Override the "read" action with arbitrary logic.
        get => $this->first . " " . $this->last;
 
        // Override the "write" action with arbitrary logic.
        set($value) { 
            [$this->first, $this->last] = explode(' ', $value);
        }
    }
}

Additionally, as this functionality makes it natural to use a public property as part of an API, this RFC allows interfaces to declare properties and whether they should be readable, writeable, or both.

interface Named
{
    // Objects implementing this interface must have a readable
    // $fullName property.  That could be satisfied with a traditional
    // property or a property with a "get" hook.
    public string $fullName { get; }
}
 
// The "User" class above satisfies this interface, but so does:
 
class SimpleUser implements Named
{
    public function __construct(public readonly string $fullName) {}
}

Taken together, these behaviors allow for much shorter, more robust code.

Detailed Proposal

This RFC applies to object properties only, not static properties. Static properties are unaffected by this RFC.

For a property to use a hook, it must replace its trailing ; with a code block denoted by { }. Inside the block are one or more hook implementations, for which the order is explicitly irrelevant. It is a compile error to have an empty hook block.

The get and set hooks override the PHP default read and write behavior. They may be implemented individually or together. Within each hook, the “unfiltered” value of the property is available as $field. Note that $field is a compile-time macro that translates to $this->propertyName in the engine. The main implication of that is more esoteric variable forms (such as $$var) will not work with $field, though they shouldn't be used anyway. Using $this->propertyName directly is supported, but not recommended.

If a property has at least one hook, and none of them make use of $field, then no backing store value will be created in the engine. Such properties are known as “virtual properties,” as they have no materialized stored value.

get

The get hook, if implemented, overrides PHP's default read behavior.

class User
{
    public function __construct(private string $first, private string $last) {}
 
    public string $fullName {
        get { 
            return $this->first . " " . $this->last;
        }
    }
}
 
$u = new User('Larry', 'Garfield');
 
// prints "Larry Garfield"
print $u->fullName;

The get hook body is an arbitrarily complex method body, which MUST return a value that is type compatible with the property.

The example above creates a virtual property, as there is at least one hook and it does not use $field. Because it is virtual, there is no default set behavior (as there's nowhere to save to). Thus, any attempt to write to the property will result in an Error being thrown.

The following example does make use of $field, however, and thus a backing value will be created, and write operations will simply write to the property as normal.

class Loud
{
    public string $name {
        get {
            return strtoupper($field);
        }
    }
}
 
$l = new Loud();
$l->name = 'larry'; // The stored value is "larry"
 
print $l->name; // prints "LARRY"

In this example, $name is a stored property, so it may be freely written to (subject to scope visibility rules, of course). Read accesses, however, will go through the provided hook body, which capitalizes the value.

set

The set hook, if implemented, overrides PHP's default write behavior.

class User
{
    public function __construct(private string $first, private string $last) {}
 
    public string $fullName {
        set ($value) {
            [$this->first, $this->last] = explode(' ', $value);
        }
    }
 
    public function getFirst(): string {
        return $this->first;
    }
}
 
u = new User('Larry', 'Garfield');
 
$u->fullName = 'Ilija Tovilo';
 
// prints "Ilija"
print $u->getFirst();

The set hook body is an arbitrarily complex method body, which accepts one argument. The argument is implicitly typed to the same type as the property. Its return type is unspecified, and will silently be treated as void.

Specifying the argument name is optional. If not specified, it defaults to $value. That is, the following set hook is identical to the previous:

public string $fullName {
    set {
        [$this->first, $this->last] = explode(' ', $value);
    }
}

The above example creates a virtual property. As there is no get hook, no read operation from $fullName is allowed and will throw an Error. This particular usage pattern is not common, but valid.

Alternatively, the following example creates a stored property, and thus read actions will proceed as normal.

class User {
    public string $username {
        set($value) {
            if (strlen($value) > 10) throw new \InvalidArgumentException('Too long');
            $field = strtolower($value);
        }
    }
}
 
$u = new User();
$u->username = "Crell"; // the set hook is called
print $u->username; // prints "crell", no hook is called
 
$u->username = "something_very_long"; // the set hook throws \InvalidArgumentException.

We expect this “validate on set” use case to be particularly common.

A set hook may optionally declare an argument type that is contravariant (wider) from the type of the property. That allows the set body to accept a more permissive set of values. The type of the value returned by get must still conform to the declared type, however.

That allows, for example, behavior like this:

use Symfony\Component\String\UnicodeString;
 
class Person
{
    public UnicodeString $name {
        set(string|UnicodeString $value) {
            $field = $value instanceof UnicodeString ? $value : new UnicodeString($value);        
        }
    }
}

That allows both strings and UnicodeString objects to be passed in, but normalizes the value to UnicodeString to enforce a consistent and reliable type when reading it (either internally or externally).

Although it is not often used, the = assignment operator is an expression that returns a value. The value returned is already slightly inconsistent, however. In the case of typed properties, that is the value the property holds after the assignment, which may include type coercion. For a property assignment that triggers __set, there is no reasonably defined “value the property holds”, so the value returned is always the right-hand-side of the expression. The set hook has the same behavior as __set, for the same reason.

class C {
    public array $_names;
    public string $names {
        set {
            $this->_names = explode(',', $value);
        }
    }
}
$c = new C();
var_dump($c->names = 'Ilija,Larry'); // 'Ilija,Larry'
var_dump($c->_names); // ['Ilija', 'Larry']

In strict type mode, that means the only case where the result of the = operator changes is when assigning an int to a float. In weak mode, there are additional cases where implicit type casting would change the type, but not the value. These same changes already happen today with __set, using the evaluated value of = is rare, and at most can change the type of the resulting value in a coercion-compatible way. For that reason we consider that an acceptable edge case.

Abbreviated forms

There are two shorthand notations supported, beyond the optional argument to set.

First, if a hook's body is a single expression, then the { } and return statement may be omitted and replaced with =>, just like with arrow functions.

Second, if there is one and only one hook, and that hook is get, then the hook name and wrapping {} may be omitted and replaced with =>.

That means the following three examples are all semantically identical:

class User
{
    public function __construct(private string $first, private string $last) {}
 
    public string $fullName {
        get { 
            return $this->first . " " . $this->last;
        }
    }
}
 
class User
{
    public function __construct(private string $first, private string $last) {}
 
    public string $fullName {
        get => $this->first . " " . $this->last;
    }
}
 
class User
{
    public function __construct(private string $first, private string $last) {}
 
    public string $fullName => $this->first . " " . $this->last;
}

Although the code above resembles short-closure syntax, the set operation will still return void, even if using the short-hand syntax above. That is, the following is an example of legal syntax:

class User
{
 
    private array $modified = [];
 
    public string $fullName {
        get => $this->first . " " . $this->last;
        set => [$this->first, $this->last] = explode(' ', \ucfirst($value));
    }
 
    public function __construct(private string $first, private string $last) {}
}

By way of comparison, here is the same logic implemented via methods in PHP 8.3:

class User
{
    public function __construct(private string $first, private string $last) {}
 
    public function getFullName(): string {
        return $this->first . " " . $this->last;
    }
 
    public function setFullName(string $value): void {
        $value = \ucfirst($value);
        [$this->first, $this->last] = explode(' ', $value);
    }
}

Scoping

All hooks operate in the scope of the object being modified. That means they have access to all public, private, or protected methods of the object, as well as any public, private, or protected properties, including properties that may have their own property hooks. Accessing another property from within a hook does not bypass the hooks defined on that property.

The most notable implication of this is that non-trivial hooks may sub-call to an arbitrarily complex method if they wish. For example:

class Person {
    public string $phone {
        set => $field = $this->sanitizePhone($value);
    }
 
    private function sanitizePhone(string $value): string {
        $value = ltrim($value, '+');
        $value = ltrim($value, '1');
 
        if (!preg_match('/\d\d\d\-\d\d\d\-\d\d\d\d/', $value)) {
            throw new \InvalidArgumentException();
        }
        return $value;
    }
}

Note that $field is only valid in the hook itself, not in a sub-called method. That is, writing to $field from within sanitizePhone() would just write to a local variable by that name, not to the property.

References

Because the presence of hooks intercept the read and write process for properties, they cause issues when acquiring a reference to a property or with indirect modification (such as $this->arrayProp['key'] = 'value';).

For that reason, the presence of any hook must necessarily also disallow acquiring a reference to a property or indirect modification on a property.

For example:

class Foo
{
    public string $bar;
 
    public string $baz {
        get => $field;
        set => $field = strtoupper($value);
    }
}
 
$x = 'beep';
 
$foo = new Foo();
// This is fine; as $bar is a normal property.
$foo->bar = &$x;
 
// This will error, as $baz is a 
// hooked property and so references are not allowed.
$foo->baz = &$x;

For the vast majority of properties this causes no issue, as reading or writing to properties by reference is extremely rare. However, there are some edge cases where references to properties are necessary. Arguably, the lack of support for references would technically make adding hooks to a property a nominal BC break for that class, but property references are so rare that we consider it acceptable, especially with the addition below.

There is one exception to the above: if a property is virtual, then there is no presumed connection between the get and set operations. Since the set hook couldn't be expected to update-in-place anyway, we have chosen to allow references to be opt-in for the get hook on virtual properties, only. To do so, the get hook may be prefixed with &, which will cause it to return a reference to whatever its body returns, rather than the value itself. Subsequent writes to that reference will not trigger the set hook.

That means the following is allowed:

class Foo
{
    private string $_baz;
 
    public string $baz {
        &get => $this->_baz;
        set => $this->_baz = strtoupper($value);
    }
}
 
$foo = new Foo();
 
// This invokes "set", and sets $_baz to "BEEP".
$foo->baz = 'beep';
 
// This assigns $x to be a reference directly to $_baz
$x =& $foo->baz;
 
// This assigns "boop" to $_baz, bypassing the set hook.
$x = 'boop';

Setting by reference, however, is not supported, as its semantics are not well-defined. This behavior mirrors how the magic methods __get() and __set() handle references. (They are, in a sense, generic virtual properties.)

Additionally, iterating an object's properties by reference will throw an error if any of the properties have hooks defined.

foreach ($someObjectWithHooks as $key => $value) {
    // Iterates all in-scope properties, using the 'get' operation if defined. 
}
 
foreach ($someObjectWithHooks as $key => &$value) {
    // Throws an error if any in-scope property has a hook.
}

Arrays

There is an additional caveat regarding array properties. Assigning a value to an array property consists of obtaining a pointer to that property and modifying it in place without knowledge of whether that array came from a property with hooks. It is also common in some systems to acquire references to arrays and modify them in some other place. That would, as above, bypass any hooks that have been defined.

Furthermore, providing a good API for an array modification seems impossible. The simplest approach would be to copy the array, modify it accordingly, and pass it to set hook. This would have a large and obscure performance penalty, and a set implementation would have no way of knowing which values had changed, leading to, for instance, code needing to revalidate all elements even if only one has changed.

Another approach might be a separate hook, e.g. setOffset(array $keys, $value) where $keys is a list of keys (for example, $foo['bar']['baz']). Assuming the user would like to apply the change to the array, they would need to iterate the keys, fetch the offset by-reference, and at the end assign the value to the last reference. This has terrible performance characteristics and is non-trivial to implement for the end-user.

Both of these possible approaches also handle only explicit additions and modification. They would not address appending ([]) or operations such as unset, which would introduce even more complexity.

Because all API solutions that come to mind are bad it is the opinion of the authors that for arrays, dedicated mutator methods are always the superior API choice. Therefore, while hooks may be applied to array properties attempting to write to a key of a backed array property that has hooks (using [] or ['some key']) will result in a runtime error being thrown.

This restriction does not apply to get operations, as the hook would run to return the whole array, and then dereferencing off of it for an individual key is safe.

To summarize in code:

class C
{
    private bool $hasChanged = false;
 
    public array $arr {
        get => $field + ['extra' => 'stuff'];
        set {
            $this->hasChanged = true;
            $field = $value;
        }
    }
}
 
$c = new C();
 
// OK, returns the whole array,
// with the extra key on the end.
print_r($c->arr);
 
// OK; the hook returns the array by value,
// then the 'beep' key is accessed.
print $c->arr['beep'];
 
// OK.  The entire array is overwritten,
// and passed to set as $value to do with
// as it pleases.
$c->arr = ['new' => 'array'];
 
// Error.
$c->arr['beep'] = 'boop';
 
// Error.
$c->arr[] = 'narf';

This check cannot be done at compile time, as we cannot reliably tell at compile time whether a property is an array or not, as it could be typed as an iterable or mixed, so runtime enforcement would be required anyway. There are also valid usecases for arrays with some hooks, such as a get-only virtual array, which make forbidding hooks entirely undesireable.

In practice, a more appropriate and idiomatic usage of hooked arrays would be like so:

class C 
{
    private array $_elements;
 
    public array $elements {
        get => $this->_elements;
    }
 
    public function addElement(string $element): void {
        $this->_elements[] = $element;
    }
}

The “passthrough” get implementation creates a virtual property, which disables writing directly to the $elements property. As it is a virtual property, returning by reference would also be allowed, if desired.

It would also be possible to make addElement() private/protected, in order to simulate private-write, public-read properties. That said, the companion Asymmetric Visibility RFC would provide a much more elegant approach.

Default values

Default values are supported on properties that have a backing store. Default values are not supported on virtual properties, as there is no natural value for the default to be assigned to.

Of note, the default value is assigned directly, and not passed through the set hook. All subsequent writes will go through the set hook. This is primarily to avoid confusion or questions about when, exactly, the set hook should run during object initialization, and is consistent with how Kotlin handles it as well.

Default values are listed before the hook block.

class User
{
    public string $role = 'anonymous' {
        set => $field = strlen($value) <= 10 ? $value : throw new \Exception('Too long');
    }
}

Inheritance

A child class may define or redefine individual hooks on a property by redefining the property and just the hooks it wishes to override. The type and visibility of the property are subject to their own rules independently of this RFC.

A child class may also add hooks to a property that had none.

class Point
{
    public int $x;
    public int $y;
}
 
class PositivePoint extends Point
{
    public int $x {
        set($x) {
            if ($x >= 0) {
                $field = $x;
            }
            throw new \InvalidArgumentException('Too small');
        }
    }
}

Each hook overrides parent implementations independently of each other.

If a child class adds hooks, any default set on the property is removed. That is consistent with how inheritance works already; if a property is redeclared in a child, its default is removed or must be re-assigned.

Accessing parent hooks

A hook in a child class may access the parent class's hook it is overriding by using the parent::$prop keyword. If not accessed this way, the parent class's hook is ignored. This behavior is consistent with how all methods work. This also offers a way to access the parent class's storage, if any. That is, the above example could be rewritten:

class Point
{
    public int $x;
    public int $y;
}
 
class PositivePoint extends Point
{
    public int $x {
        set($x) {
            if ($x >= 0) {
                parent::$x::set($x);
            }
            throw new \InvalidArgumentException('Too small');
        }
    }
}

An example of overriding only a get hook could be:

class Strings
{
    public string $val;
}
 
class CaseFoldingStrings extends Strings
{
    public bool $uppercase = true;
 
    public string $val {
        get => $this->uppercase 
            ? strtoupper(parent::$val::get()) 
            : strtolower(parent::$val::get());
    }
}

Hooks may not access any other hook except their own parent on their own property.

Final hooks

Hooks may also be declared final, in which case they may not be overridden.

class User 
{
    public string $username {
        final set($value) => $field = strtolower($value);
    }
}
 
class Manager extends User
{
    public string $username {
        // This is allowed
        get => strtoupper($field);
 
        // But this is NOT allowed, because beforeSet is final in the parent.
        set => $field = strtoupper($value);
    }
}

A property may also be declared final. A final property may not be redeclared by a child class in any way, which precludes altering hooks or widening its access.

Declaring hooks final on a property that is declared final is redundant will throw an error.

class User 
{   
    // Child classes may not add hooks of any kind to this property.
    public final string $name;
 
    // Child classes may not add any hooks or override set,
    // but this set will still apply.
    public final string $username {
        set => $field = strtolower($value);
    }
}

Interfaces

A key goal for property hooks is to obviate the need for getter/setter methods in the majority case. While straightforward for classes, many value objects also conform to an interface. That interface, therefore, also needs to be able to specify what properties it includes.

This RFC therefore also adds the ability for interfaces to declare public properties, asymmetrically. An implementing class may provide the property via a normal property or hooks. Either one is sufficient to satisfy the interface.

interface I
{
    // An implementing class MUST have a publicly-readable property,
    // but whether or not it's publicly settable is unrestricted.
    public string $readable { get; }
 
    // An implementing class MUST have a publicly-writeable property,
    // but whether or not it's publicly readable is unrestricted.
    public string $writeable { set; }
 
    // An implementing class MUST have a property that is both publicly
    // readable and publicly writeable.
    public string $both { get; set; }
}
 
// This class implements all three properties as traditional, un-hooked
// properties. That's entirely valid.
class C1 implements I
{
    public string $readable;
 
    public string $writeable;
 
    public string $both;
}
 
// This class implements all three properties using just the hooks
// that are requested.  This is also entirely valid.
class C2 implements I
{
    private string $written = '';
    private string $all = '';
 
    // Uses only a get hook to create a virtual property.
    // This satisfies the "public get" requirement. It is not
    // writeable, but that is not required by the interface.
    public string $readable => strtoupper($this->writeable);
 
    // The interface only requires the property be settable,
    // but also including get operations is entirely valid.
    // This example creates a virtual property, which is fine.
    public string $writeable {
        get => $this->written;
        set => $this->written = $value;
    }
 
    // This property requires both read and write be possible,
    // so we need to either implement both, or allow it to have
    // the default behavior.
    public string $both {
        get => $this->all;
        set => $this->all = strtoupper($value);
    }
}

Interfaces are only concerned with public access, so the presence of non-public properties is both unaffected by an interface and cannot satisfy an interface. This is the same relationship as for methods. The public keyword on the property is required for syntax consistency, but other visibilities are not supported, nor is the long-deprecated var.

We have deliberately chosen to not support public string $both in interfaces, without specifying the required hooks. That is because the most common use case would be a get-only property, but it's unclear if undefined hooks should mean “get only” or “get and set”. It may also imply that the property may be referenced, which may not be the case depending on the implementing class. To avoid ambiguity, the expected operations must be specified explicitly.

Of note, an interface property that only requires get may be satisfied by a public readonly property, as the restrictions of readonly only apply on write. However, an interface property that requires set is incompatible with a readonly property, as public-write would be disallowed.

Abstract properties

An abstract class may declare an abstract property, for all the same reasons as an interface. However, abstract properties may also be declared protected, just as with abstract methods. In that case, it may be satisfied by a property that is readable/writeable from either protected or public scope. Abstract private properties are not allowed and will result in a compile-time error, just as with methods.

abstract class A
{
    // Extending classes must have a publicly-gettable property.
    abstract public string $readable { get; }
 
    // Extending classes must have a protected- or public-writeable property.
    abstract protected string $writeable { set; }
 
    // Extending classes must have a protected or public symmetric property.
    abstract protected string $both { get; set; }   
}
 
class C extends A
{
    // This satisfies the requirement and also makes it settable, which is valid.
    public string $readable;
 
    // This would NOT satisfy the requirement, as it is not publicly readable.
    protected string $readable;
 
    // This satisfies the requirement exactly, so is sufficient. It may only
    // be written to, and only from protected scope.    
    protected string $writeable {
        set => $field = $value;
    }
 
    // This expands the visibility from protected to public, which is fine.
    public string $both;
}

An abstract property on an abstract class may provide implementations for any hook, but must have either get or set declared but not defined (as in the example above). A property on an interface may not implement any hooks.

abstract class A
{
    // This provides a default (but overridable) set implementation, and requires 
    // child classes to provide a get implementation.
    abstract public string $foo { 
        get;
        set { $field = $value};
    }
}

As with interfaces, omitting a hook indicates no requirement for it, and specifying neither hook is not supported, for all the same reasons as interfaces.

As with interfaces, a get-only abstract property may be satisfied by a readonly property. A set-requiring abstract property is incompatible with readonly.

Property type variance

Normal properties are neither covariant nor contravariant; their type may not change in a subclass. The reason for that is “get” operations MUST be covariant, and “set” operations MUST be contravariant. The only way for a property to satisfy both requirements is to be invariant.

With abstract properties (on an interface or abstract class) or virtual properties, it is possible to declare a property that has only a get or set operation. As a result, abstract properties or virtual properties that have only a get operation required MAY be covariant. Similarly, an abstract property or virtual property that has only a set operation required MAY be contravariant.

Once a property has both a get and set operation, however, it is no longer covariant or contravariant for further extension.

class Animal {}
class Dog extends Animal {}
class Poodle extends Dog {}
 
interface PetOwner 
{
    // Only a get operation is required, so this may be covariant.
    public Animal $pet { get; }
}
 
class DogOwner implements PetOwner 
{
    // This may be a more restrictive type since the "get" side
    // still returns an Animal.  However, as a native property
    // children of this class may not change the type anymore.
    public Dog $pet;
}
 
class PoodleOwner extends DogOwner 
{
    // This is NOT ALLOWED, because DogOwner::$pet has both
    // get and set operations defined and required.
    public Poodle $pet;
}

Property magic constant

Within a property hook, the special constant __PROPERTY__ is automatically defined. Its value will be set to the name of the property. This is mainly useful for repeating self-referential code. See the “cached derived property” example linked below for a complete use case.

Interaction with traits

Properties in traits may declare hooks, just like any other property. However, as with normal properties, there is no conflict resolution mechanism provided the way methods have. If a trait and a class where it is used both declare the same property with hooks, an error is issued.

We anticipate that being a very rare edge case, and thus no additional resolution machinery is necessary.

Interaction with readonly

readonly properties work by checking if the backing store value is uninitialized. A virtual property has no backing store value to check. While technically an inherited readonly property would allow accessing its parent's stored value, in practice it would be non-obvious when readonly works on properties with hooks. Moreover, providing a get hook on an overridden property would further complicate the notion of a “initialized” value.

For that reason, a readonly property with a get or set hook is disallowed and will throw a compile error. That also means that a child class may not redeclare and add hooks to a readonly property, either.

Interaction with magic methods

PHP 8.3 will invoke the __get(), __set(), __isset(), and __unset() magic methods if a property is accessed and it is either not defined, OR it is defined but not visible from the calling scope. The presence of hooks on a defined property does not change that behavior. Naturally the property will be defined if it has hooks; however, if the property is not visible in the calling scope then the appropriate magic method will be called just as if there were no hooks.

Within the magic methods, the property will be visible and therefore accessible. Reads or writes to a hooked property will behave the same as from any other method, and thus hooks will still be invoked as normal.

class C
{
    private string $normalizedName;
    private string $name {
        get => $field;
        set => $field = ucfirst($value);
    }
 
    public function __set($var, $val)
    {
        print "In __set\n";
        $this->$var = $val;
    }
}
 
$c = new C();
 
$c->name = 'picard';
 
// prints "In __set"
// $c->name now has the value "Picard"

Interaction with isset() and unset()

If a scope-visible property implements get, then isset() will invoke the get hook and return true if the value is non-null. That is, isset($o->foo), where $foo has a get hook, is equivalent to !is_null($o->foo). This behavior is consistent with how isset() interacts with __get today.

If a property has a backing value and there is no get hook, it will operate on the property value directly the same as if there were no hooks.

If a property is virtual and has no get hook, calling isset() will throw an Error.

If a property implements any hook, then unset() is disallowed and will result in an error. unset() is a very narrow-purpose write operation; supporting it directly would involve bypassing any set hook that is defined, which is undesireable. If in the future a compelling need can be found for it, that may justify a dedicated unset hook. (See Future Scope.)

Interaction with constructor property promotion

As of PHP 8.0, properties may be declared inline with the constructor. That creates an interesting potential for complexity if the property also includes hooks, as the hooks may be arbitrarily complex, and therefore long, leading to potentially tens of lines of code technically within the constructor's method signature.

On the other hand, we expect the use of the set hook for validation (as shown in various examples here) to be fairly popular, including validation on promoted properties. Making them incompatible would undercut the value of both tremendously. (Virtual properties make little sense to make promoted.)

After much consideration, the authors have opted to allow hooks to be implemented within constructor property promotion. While pathological examples could certainly be shown, we anticipate in practice that the impact will be far less. In particular, the shorthand version of hook bodies and the ability to call out to private methods if they get complicated partially obviate the concern about syntactic complexity.

For example, we predict the following to be the extent of most combinations of hooks and promotion:

class User
{
    public function __construct(
        public string $username { set => $field = strtolower($value); }
    ) {}
}

Which is, all things considered, not too bad for the level of power it gives.

Interaction with serialization

The behavior of properties with hooks when serialized has been designed to model the behavior of non-hooked properties as closely as possible.

There are several serialization contexts to consider. Their behavior is sumamrized below, with explanations afterward.

serialize() and var_dump() are both intended to show the internal state of the object. For that reason, for backed properties they will store/display the raw value of the property, without invoking get. Virtual properties, which have no backing store of their own, will be omitted.

Similarly, unserialize() will write to a property's backing value directly, without invoking set. If the input has a value for a virtual property, an error will be thrown.

Note that if the __serialize() or __unserialize() magic methods are used, those will run like any other method and therefore read through the get hook.

When casting an object to an array ($arr = (array) $obj), currently the visibility of properties is ignored; the keys returned may have an extra prefix in them to indicate that they were private, but that's it. As this operation currently reveals internal implementation details, it also will not invoke the get hook.

JsonSerializable is a non-issue; its jsonSerialize method will be called as a normal method and have the same access to properties as any other method (that is, through the get hook if present), and may return whatever value it wishes.

In PHP 8.3, using json_encode() on an object that does not implement JsonSerializable will return a JSON object of key/value pairs of the public properties only, regardless of what scope it is called from. The intent is to serialize the “public face” of the object. For that reason, public properties with a get hook will be included, and the get hook invoked, regardless of whether the property is virtual or not.

get_object_vars() is also scope-aware, and thus is not supposed to have access to internal state. Functionally, it is equivalent in behavior to calling foreach over an object. Its behavior with hooks is therefore the same: any property readable in scope will be included, and a get hook called if defined, regardless of whether the property is virtual or not.

var_export() is an interesting case. Its intent is to create an export of the object's internal state, and it bypasses visibiilty control, but in a way that it may be re-hydrated entirely from user-space code in the __set_state() method. __set_state() necessarily must send any assignments through the set hook, if defined.To minimize asymmetry, therefore, we have chosen to invoke get hooks on properties for var_export().

Reflection

ReflectionProperty has several new methods to work with hooks.

  • getHooks(): array returns an array of \ReflectionMethod objects keyed by the hook they are for. So for example, a property with both get and set hook will return a 2 element array with keys get and get, each of which are a \ReflectionMethod object. The order in which they are returned is explicitly undefined. If an empty array is returned, it means there are no hooks defined.
  • getHook(string $hook): ?\ReflectionMethod returns the corresponding \ReflectionMethod object, null if it is not defined, or throw an error if a non-supported hook is requested (e.g., getHook('beep')).
  • isVirtual(): bool, returns true if the property has no backing value, and false if it does. (That is, all existing properties without hooks will return false.)
  • getRawValue(object $object): mixed will return the raw backing value of the property, without caling a get hook. If there is no hook, it behaves identically to getValue(). If the property is virtual, it will throw an error. On a static property, this method will always throw an error.
  • setRawValue(object $object, mixed $value): void will, similarly, set the raw value of a property without invoking a set hook. If there is no hook, it behaves identically to setValue(). If the property is virtual, it will throw an error. On a static property, this method will always throw an error.
  • The existing getValue() method will invoke a get hook if one is defined, regardless of whether the property is virtual or not. If a property is write-only (virtual and has only a set hook defined), an error will be thrown.
  • The existing setValue() method will invoke a set hook if one is defined, regardless of whether the property is virtual or not. If a property is get-only (virtual and has only a get hook defined), an error will be thrown.

There is also a \ReflectionProperty::IS_VIRTUAL constant for use in property filters.

The returned \ReflectionMethod objects will have the class the property is on as its declaring class (returned by getDeclaringClass()). Its return and parameter types will be as defined by the rules above in the hooks section. Its getName() method will return ClassName::$prop::get (or set, accordingly).

Hooks defined by a parent class's property will be included and available, the same as if they were defined on the property directly, unless overridden in the child class.

Attributes

Hook implementations are internally implemented as methods. That means hooks may accept method-targeted attributes. They may be accessed via reflection in the usual way, once the \ReflectionMethod object is obtained.

#[Attribute(Attribute::TARGET_METHOD)]
class A {}
 
#[Attribute(Attribute::TARGET_METHOD)]
class B {}
 
class C {
    public $prop { 
        #[A] get {}
        #[B] set {}
    }
}
 
$getAttr = (new ReflectionProperty(C::class, 'prop'))
    ->getHook('get')
    ->getAttributes()[0];
$aAttrib = $getAttr->getInstance();
 
// $aAttrib is an instance of A.

Hook parameters may also accept parameter-targeted attributes, as expected.

class C {
    public $prop { 
        set(#[SensitiveParameter] $value) {
            throw new Exception('Exception from $prop');
        }
    }
}
 
$c = new C();
$c->prop = 'secret';
// Exception: Exception from $prop in %s:%d
// Stack trace:
// #0 example.php(4): C->$prop::set(Object(SensitiveParameterValue))
// #1 {main}

Frequently Asked Questions

Why isn't asymmetric visibility included, like in C#?

Kotlin, Swift, and C#, all of which have a similar accessor model to that shown here, all support asymmetric visibility in addition to property hooks (by whatever name). However, they all use different syntaxes. In some but not all cases, the visibility is placed on the get or set hook.

That would cause a problem for PHP. As noted above, property hooks are incompatible with array properties. However, there is no conceptual reason for asymmetric visibility to be incompatible with array properties, and there are ample use cases for wanting to support that.

However, using the hook-bound syntax for visibility would either inherently forbid asymmetric visibility on arrays (undesirable), or necessitate more complex syntax to determine if references should or should not be disabled on a property. Both are poor options.

For that reason, any concept of asymmetric visibility has been omitted from this RFC. Should asymmetric visibility be determined a desirable feature in the future, a left-side syntax as used by Swift and as demonstrated in the original Asymmetric Visibility RFC would be a complementary addition, and the best option in practice.

Why do set hooks not return the value to set?

An alternate implementation would be to have a set hook return a value, and that value be what is assigned. While the seemingly obvious answer, it is not as robust as the approach described here.

Primarily, that is because it disallows any action after the assignment happens. Only “before” actions are allowed, because once the value is returned no further code in the hook executes (like any other function).

Some actions, however, will want to run after the assignment is confirmed. Change-logging, for instance, shouldn't record until the change is confirmed and “committed.” That's because the assignment itself still might fail, for instance if the passed $value has a wider type than the property itself, and the code in the hook fails to propertly normalize it.

For that reason, we need a way to assign and confirm the property write while still allowing code to run after it. Swift approaches that with a separate “beforeSet” and “didSet” hook; howevever, as this design opts for a single set hook rather than three, the return value is not a viable option.

Why the $field magic value?

This is primarily for convenience, as $field is shorter to type, and more consistent. It also makes common code easier to copy-paste (which, for better or worse, is likely to happen for code fragments this small). Additionally, using a generic variable name will almost certainly be necessary for reusable hooks, should those ever be adopted. (See Future Scope, below.)

Of note, Kotlin uses the exact same design with exact same variable name, and we are not aware of it causing any confusion in the Kotlin world.

Usage examples

We have collected a series of examples that show what we expect to be typical hook usage. (Or, arguably, the kind of things one could do that wouldn't require adding a method for in case you want to do them in the future.) It is non-normative, but gives a sense of how hooks can be used to improve a code base (or things that can be added later without needing to create methods “just in case”).

In the interest of brevity, we have placed the examples in an external document, available here: Usage examples

Backward Incompatible Changes

There is one subtle BC break due to accessing parent property hooks. Specifically, in this code:

class A {
    public static $prop = 'C';
}
 
class B extends A {
    public function test() {
        return parent::$prop::get();
    }
}
 
class C {
    public static function get() {
        return 'Hello from C::get';
    }
}

Currently, parent::$prop would resolve to "C", and then the C::get() method would be called.

With this RFC, *if* the method name is the same as a hook, then the above code would error out with a message about trying to access a parent hook when not in a hook. If the method is not the same name as a hook, there is no change in behavior.

The previous logic could be achieved by using a temporary variable:

class B extends A {
    public function test() {
        $class = parent::$prop;
        return $class::get();
    }
}

As the above code is very rare in the wild and rather contrived, and easily worked around, we feel this edge case is acceptable.

Open questions

Proposed PHP Version(s)

PHP 8.4.

Future Scope

isset and unset hooks

PHP supports magic methods for __isset and __unset. While it is tempting to allow those as hooks as well, the authors feel their use is limited. They have therefore been omitted. However, it is possible to reintroduce them in a future RFC should valid use cases be shown.

Reusable hooks

Swift has the ability to declare hook “packages” that can be applied to multiple properties, even in separate classes. That further helps reduce boilerplate, without having to pack even more logic into the type system. In a sense, it does for hooks what PHP traits do for methods and properties. While that is potentially useful, it would be a whole big feature unto itself. The authors therefore opted to avoid that for now. It is an addition that could be pursued in the future if it's found to be useful.

Proposed Voting Choices

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

Implementation

After the project is implemented, this section should contain

  1. the version(s) it was merged into
  2. a link to the git commit(s)
  3. a link to the PHP manual entry for the feature
  4. a link to the language specification section (if any)

References

Links to external references, discussions or RFCs

Rejected Features

Keep this updated with features that were discussed on the mail lists.

rfc/property-hooks.txt · Last modified: 2024/02/21 22:39 by crell