rfc:property-hooks

PHP RFC: Property hooks

  • Version: 0.9
  • Date: 2022-12-01
  • Author: Ilija Tovilo (tovilo.ilija@gmail.com), Larry Garfield (larry@garfieldtech.com)
  • Status: Draft
  • Implementation: Pending

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 took for common property interactions.

The combination of this RFC and the Asymmetric Visibility RFC effectively replicate and replace the previous Property Accessors RFC.

The design and syntax below is derived from both Swift and C#, although not an exact copy of either.

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.2, 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 {
        beforeSet {
            if (strlen($value) === 0) {
                throw new ValueError("Name must be non-empty");
            }
            return $value;
        }
    }
 
    public function __construct(string $name) {
        $this->name = $name;
    }
}

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

Proposal

This RFC allows attaching one or more of a fixed set of “hooks” to declared properties. Some hooks are mutually-dependent, while others are effectively independent. The hooks proposed by this RFC cover most common cases, but in concept more could be added in the future.

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 syntax error to have an empty hook block.

Hooks may not be applied to properties typed array. See the section below for an explanation.

Of note, a default value for the property, if any, goes before the hook list. That is:

class Point {
    public int $x = 5 { beforeSet => $value ?: throw new \InvalidArgumentException(); }
    public int $y = 5 { beforeSet => $value ?: throw new \InvalidArgumentException(); }
}

get and set

The get and set hooks overwrite the PHP default read and write behavior. They may be implemented individually or together. If either one is implemented, PHP will not create any storage property on the object. The developer is on their own to implement whatever storage behavior is appropriate.

get

If a get hook is implemented, then PHP will create no automatic storage for that property. Instead, reads of that property will invoke the defined hook.

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.

If a get hook is defined but no set hook, then any attempt to write to the property will result in an error.

set

If a set hook is implemented, then PHP will create no automatic storage for that property. Instead, writes to that property will invoke the defined hook.

class User
{
    public function __construct(private string $first, private string $last) {}
 
    public string $fullName {
        get { 
            return $this->first . " " . $this->last;
        }
        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 and has a void return. The argument is implicitly typed to the same type as the property, but may not be specified explicitly.

ILIJA: I'm still open to allowing this to be enforced on return, not on set, so that the set hook can type-normalize a value. Is that doable? Can I convince you to allow that?

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 {
        get { 
            return $this->first . " " . $this->last;
        }
        set {
            [$this->first, $this->last] = explode(' ', $value);
        }
    }

If a set hook is defined but no get hook, then any attempt to read from the property will result in an error. Such “write only” properties are of limited (but probably non-zero) value, but do not cause any issues for other functionality.

In practice, most set implementations will also include a get, but not all get implementations will include a set.

beforeSet

The beforeSet hook intercepts values before they are assigned to a property, and optionally modifies them. It does not affect PHP's storage of the value; that is, if only beforeSet is implemented, there will still be a real value stored on a real property on the object. beforeSet is most useful for validation or sanitization.

class User {
 
    public string $username {
        beforeSet($value) {
            return strtolower($value);
        }
    }
}
 
$u = new User();
 
$u->username = "JamesKirk";
 
// prints "jameskirk"
print $u->username;

The beforeSet hook body is an arbitrarily complex method body, which accepts one untyped argument. It MUST return a value, and that value MUST be type-compatible with the property. In this example, the value is being sanitized to lowercase. If the hook wishes to modify the value being saved conditionally, it may do so and sometimes return $value unmodified.

Specifying the argument name is optional. If not specified, it defaults to $value.

A beforeSet hook may also be used for validation by throwing an exception should the value not meet some arbitrary criteria.

class User {
 
    public string $username {
        beforeSet($value) {
            if (strlen($value) > 10) throw new \InvalidArgumentException('Too long');
            return strtolower($value);
        }
    }
}

Whatever value is returned from beforeSet will be assigned to the property, either directly or through a defined set hook.

afterSet

The afterSet hook intercepts values after they are assigned to a property. It may not modify the property, but may read from it. If a get hook is defined, the read attempt will invoke the get hook. The afterSet hook is mainly useful for logging, change tracking, and synchronizing changes between properties.

class Person {
    private array $modified = [];
 
    public string $name {
        afterSet($value) {
            $this->modified['name'] = $value;
        }
    }
 
    public int $age {
        afterSet($value) {
            $this->modified['age'] = $value;
        }
    }
 
    public function save(): void
    {
        foreach ($this->isModified as $prop => $val) {
            print "$prop was set to $val\n";
        }
    }
}
 
$p = new Person();
 
$p->name = 'Larry';
 
// prints: name was set to Larry
$p->save();

The afterSet hook body is an arbitrarily complex method body, which accepts one untyped argument and is return void.

Specifying the argument name is optional. If not specified, it defaults to $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 stub out to an arbitrarily complex method if they wish. For example:

class Person {
    public string $phone {
        beforeSet { return $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;
    }
}

References and arrays

Because the presence of hooks intercept the read and write process for properties, they cause issues when acquiring a reference to a property. For that reason, the presence of any hook must necessarily also disallow acquiring a reference to a property.

For the vast majority of properties this causes no issue. The one exception is array properties. Assigning a value to an array property consists of obtaining a reference to that property and modifying it in place. That would, as above, bypass any hooks that have been defined.

At this time, the authors believe the best option is to disallow hooks on array properties. Any property typed array with hooks will cause a syntax error.

An iterable property is still supported. However, attempting to assign to it with [] may still result in a runtime error if the underlying value is an array.

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) {
    // Works as expected.
}
 
foreach ($someObjectWithHooks as $key => &$value) {
    // Throws an error
}

Abbreviated forms

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

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;
}

For void-return hooks, if the single-expression results in a value it will be silently discarded. 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(' ', $value);
        beforeSet => \ucfirst($value);
        afterSet => $this->modified['first'] = true && $this->modified['last'] = true;
    }
 
    public function __construct(private string $first, private string $last) {}
}

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

class User
{
    private array $modified = [];
 
    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);
        $this->modified['first'] = true;
        $this->modified['last'] = true;
    }
}

Interaction with asymmetric visibility

Swift and C#, the languages on which this design is modeled, both support asymmetric visibility in addition to property hooks (by whatever name). However, they use different syntaxes. The C# syntax in particular includes the visibilitiy 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 C#-style syntax for visibility would either inherently forbid asymmetric visibility on arrays (undesirable), or necessitate much more complex logic for both the engine and developers 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 only viable option.

Interaction with readonly

readonly properties have the effect of forcing private set scope, and limiting the property to a single set operation. That behavior is independent of hooks, and therefore the two are compatible.

The one caveat is if a get hook is implemented. It is entirely possible for a get hook to return different values at different times (say, if the $first property were modified, as in the examples above). That may give the appearance of a property being non-readonly when it actually is.

ILIJA: I don't actually know what we should do here. Make readonly mean cache? Disallow it? Say “caveat coder?” I'm not sure.

Interaction with magic methods

PHP 8.2 will invoke the __get or __set 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 __get or __set will be called just as if there were no hooks.

Within the __get or __set 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 $name {
        beforeSet => 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 property implements hooks other than get or set, then isset() is unaffected.

If a 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 implements any hook, then unset() is disallowed and will result in an error. Its semantics are unclear, and it rarely has a logical meaning anyway.

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, the beforeSet hook in particular is extremely useful for validation, including validation on promoted properties. Making them incompatible would undercut the value of both tremendously. That is true to a lesser extent for afterSet. (Properties with get and set hooks make little sense to include in promotion.)

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 { beforeSet => strtolower($username); }
  ) {}
}

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

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 {
      beforeSet($x) => $x >= 0 ? $x : throw new \InvalidArgumentException('Too small');
  }
}

Each hook overrides parent implementations independently of each other.

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.

class P
{
    public string $prop { beforeSet => strtolower($value); }
}
 
class C extends P
{
    public string $prop { beforeSet => ucfirst(parent::$prop::beforeSet($value)); }
}
 
$c = new C();
 
$c->prop = "HELLO";
 
// Prints "Hello"
print $c->prop;

If the parent property has no beforeSet or afterSet hook defined when it is accessed, an error will be thrown. This is also consistent with how methods work.

For get/set hooks, this also offers a way to access the parent class's storage, if any.

class P 
{
    public string $prop = 'prop';
}
 
class C extends P 
{
    public string $prop {
        get => parent::$prop::get(); 
        set => parent::$prop::set($value);
    }
}

The example shown here is essentially a “no op” implementation, but demonstrates how a property in a child class may add get/set hooks without having to implement its own storage. If the parent property has no hooks (it is using automatic storage), a pass-through get and set hook is generated so the above code will always work.

A more reaistic example could be:

class Strings
{
    public string $val;
}
 
class CaseFoldingStrings
{
    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 beforeSet($value) => strtolower($value);
    }
}
 
class Manager extends User
{
    public string $username {
        // This is allowed
        afterSet => $this->modifications['username'] = true;
        // But this is NOT allowed, because beforeSet is final in the parent.
        beforeSet => strtoupper($value);
    }
}

A property may also be declared final. That has the effect of declaring all existing hooks final (if any), and forbidding a child class from declaring additional hooks.

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 beforeSet,
    // but this beforeSet will still apply.
    public final string $username {
        beforeSet($value) => 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 a virtual property (with get/set 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.
    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 = $value;
    }
 
    // This would also be an entirely valid way to satisfy the
    // interface.
    public string $both {
        beforeSet => strtolower($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.

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
{
    private string $written = '';
 
    // 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 => $this->written = $value;
    }
 
    // This expands the visibility from protected to public, which is fine.
    public string $both;
}

Abstract property types

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 neither covariant or contravariant.

With abstract properties (on an interface or abstract class) it is possible to declare a property that has only a get or set operation. As a result, abstract properties that have only a get operation required MAY be covariant. Similarly, an abstract 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 “ORM change tracking” example below for a complete use case.

Reflection

ILIJA: Oh dear oh dear...

Usage examples

This section includes a collection of examples the authors feel are representative of how hooks can and will be used. It is non-normative, but gives a sense of how hooks can be used to improve a code base.

Derived properties

This example demonstrates a virtual property that is calculated on the fly off of other values.

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

Lazy/cached properties

Sometimes, a derived property may be expensive to compute. The example above would recompute it every time. However, the ??= operator may be used to easily cache the value.

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

This does introduce a question of when to invalidate the cache. If $first changes, $full will be out of date. This is only a concern in some classes, but if it is then it may be addressed with the afterSet hook:

class User
{
    private string $full;
 
    public function __construct(
        public string $first { afterSet => unset($this->full); }, 
        public string $last { afterSet => unset($this->full); },
    ) {}
 
    public string $fullName => $this->full ??= $this->first . " " . $this->last;
}

Now, $fullName will be cached but the cache reset any time $first or $last is updated.

All of these options are entirely transparent to the caller.

Type normalization

A beforeSet method takes the same variable type as the property is declared for. However, there is no requirement that it cannot narrow the type. For example:

class ListOfStuff
{
    public iterable $items 
    {
        beforeSet => is_array($value) ? new ArrayObject($value) : $value;
    }
}

This example will accept either an array or Traversable, but will ensure that the value that gets written is always a Traversable object. That may be helpful for later internal logic.

Another example would be auto-boxing a value:

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

This example ensures that the $name property is always a UnicodeString instance, but allows users to write PHP strings to it. Those will get automatically up-converted to UnicodeStrings, which then ensures future code only has one type to have to worry about.

ILIJA: This is why I want to allow a wider type on the set and beforeSet hooks, so that you can type the property as just UnicodeString, but still allow strings to be written to it. As is, this is a bit misleading as it means the code says reads may get back a string, but in practice they can only get back UnicodeString.

Validation

As mentioned, one of the main uses of beforeSet is validation.

class Request
{
  public function __construct(
      public string $method = 'GET' { beforeSet => $this->normalizeMethod($value); },
      public string|Url $url { beforeSet => $url instanceof Url ? $url :  },
      public array $body,
  ) {}
 
  private function normalizeMethod(string $method): string
  {
      $method = strtoupper($method);
      if (in_array($method, ['GET', 'POST', 'PUT', 'DELETE', 'HEAD']) {
          return $method;
      }
      throw new \InvalidArgumentException("$method is not a supported HTTP operation.");
  }
}

This example combines with the previous. It allows only select HTTP methods through, and forces upcasting the URL to a URL object. (Presumably the URL constructor contains logic to validate and reject invalid URL formats.)

Synchronized changes

At times, two properties must be kept in sync with each other. Updating them in place (or creating a new object with-er style) is fine, but updating one must update the other.

As an example, consider a Request object (similar to PSR-7), where the uri property's host field must be kept in sync with the header list's host field. Today, and even with asymmetric visibility, that would require a pair of set methods. With property hooks, that relationship can be built into the properties directly.

class Request
{
    private bool $updating = false;
 
    public URI $uri {
        afterSet($uri) {
            if (!$this->updating) {
                $this->updating = true;
                $this->headers['host'] = $uri->host;
                $this->updating = false;
            }
        }
    }
 
    public array $headers = [] {
        afterSet($headers) {
            if (!$this->updating) {
                $this->updating = true;
                $this->uri->host = $headers['host'] ?? '';
                $this->updating = false;
            }
        }
    }   
 
    // Lots of other stuff we're not dealing with right now;
}

Because the relationship is bidirectional, we need an additional semaphore to avoid an infinite loop. That's easily implemented, however. Now both $request->uri and $request->headers may be written to directly and they will always remain in sync.

ORM change tracking

Note that this example is glossing over internal details of the ORM's loading process, as those often involve wonky reflection anyway. That's not what is being discussed here.

Consider a domain object defined like this:

class Product
{
    public readonly string $sku;
 
    public string $name;
    public Color $color;
    public float $price;
}

That is trivial to define, and to read. However, it leaves open the potential to use hooks rather than needing to write this far longer version “just in case”:

class Product
{
    public readonly string $sku;
 
    private string $name;
    private Color $color;
    private float $price;
 
    // None of this is necessary.
 
    public function getName(): string
    {
        return $this->name;
    }
 
    public function setName(string $name): void
    {
        $this->name = $name;
    }
 
    public function getColor(): Color
    {
        return $this->color;
    }
 
    public function setColor(Color $color): void
    {
        $this->color = $color;
    }
 
    public function getPrice(): float
    {
        return $this->float;
    }
 
    public function setPrice(float $price): void
    {
        $this->price = $price;
    }
}

That means change tracking can be added to the object using hooks like this, without any change in the public-facing API:

class Product
{
    private array $modified = [];
 
    public bool $hasChanges => !count($this->modified);
 
    public readonly string $sku;
 
    public string $name {
        afterSet => $this->modified[__PROPERTY__] = $value;
    }
    public Color $color {
        afterSet => $this->modified[__PROPERTY__] = $value;
    }
    public float $price {
        afterSet => $this->modified[__PROPERTY__] = $value;
    }
 
    public function modifications(): array
    {
        return $this->modified;
    {
}
 
 
class ProductRepo
{
    public function save(Product $p)
    {
        // Here we're checking a boolean property that is computed on the fly.
        if ($p->hasChanges) {
            // We can get the list here, but not change it.
            $fields = $p->modifications();
 
            // Do something with an SQL builder to write just the changed properties,
            // or build an EventSource event with just the changes, or whatever.
        }
    }
}
 
$repo = new ProductRepo();
 
$p = $repo->load($sku);
 
// This is type checked.
$p->price = 99.99;
 
// This is also type checked.
$p->color = new Color('#ff3378');
 
$repo->save($p);

Backward Incompatible Changes

None.

Proposed PHP Version(s)

PHP 8.3.

RFC Impact

Open Issues

There are two design decisions that the authors have made that could be revisited if there is consensus to the contrary.

Pre-marking hook support

It is arguable that adding a hook to a property in a child class that does not have one in the parent class, or adding a hook to a public property in version 2 of a class, constitutes an API change or LSP violation, as doing so disables references. Given how rarely object references are used the authors do not feel this is a significant issue.

However, it would be possible to “pre-activate” hooks on a property, and disallow child classes from adding hooks unless that has been done. That would entail an empty hook block, like so:

class Point {
    public int $x {}
    public int $y;
}

In this case, the $x parameter has enabled hooks, and therefore no reference to it may be obtained but a child of Point may add hooks to $x. The $y parameter has not, and so a reference to it may be obtained.

The authors feel this is too much extra mental overhead for a very small edge case, but will accept the consensus on this point.

Limited array support

As noted above, array properties do not support hooks, as they require references to work as expected and references are incompatible with hooks.

However, it would be possible to support hooks but disallow the [] operator. In that case, the only operation allowed would be completely replacing the array, or obtaining a copy of it, like so:

class MyList {
    public array $values = [] {
        // Ensure no empty values.
        beforeSet {
            if (array_filter($value) != $value) {
                throw new \InvalidArgumentException('Only truthy values allowed');
            }
            return $value;
        }
    }
}
 
$l = new MyList();
 
// This will fail.
$l->values[] = 1;
 
// But this is allowed.
$a = $l->values;
$a[] = 1;
$l->values = $a;
 
// This will throw an exception due to the hook.
$a = $l->values;
$a[] = false;
$l->values = $a;

This is somewhat clumsy, but would be possible. At this time, the authors feel it is better to skip entirely for simplicity, but will accept the consensus on this point.

Unaffected PHP Functionality

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 and could result in wacky, inconsistent behavior. They have therefore been omitted. However, it is possible to reintroduce them in a future RFC should valid use cases be shown.

readonly

It may be feasible to further integrate readonly with hooks in the future. That is a task for a future RFC.

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: 2023/03/17 18:01 by crell