Na navigaci | Klávesové zkratky

Property Hooks in PHP 8.4: Game Changer or Hidden Trap?

PHP is introducing revolutionary features that could fundamentally change how we write object-oriented code. Property hooks and asymmetric visibility finally offer elegant solutions to challenges PHP developers have faced for years. Until now, we've been forced to use getters and setters for any object data manipulation. But now, we have sophisticated tools for directly controlling property access.

Property hooks let developers define custom behavior for reading and writing object properties (not static ones). This solution is far more elegant than using magic methods __get/__set – and not just syntactically. The engine is specifically optimized for hooks, delivering significantly better performance.

Let's look at a straightforward example. Consider a basic Person class with a public age property:

class Person
{
	public int $age = 0;
}

$person = new Person;
$person->age = 25;  // OK
$person->age = -5;  // OK, but this shouldn't be allowed!

Thanks to the int type, PHP ensures the age will always be an integer (a feature available since PHP 7.4). But what if we want to prevent negative ages? Without hooks, we'd need getter and setter methods, and the property would have to be private. With hooks, we can implement this elegantly:

class Person
{
	public int $age = 0 {
		set => $value >= 0 ? $value : throw new InvalidArgumentException;
	}
}

$person->age = -5;  // throws InvalidArgumentException

From the outside, the property behaves exactly like in the first example – we read and write directly to $person->age. However, the hook gives us complete control over what happens during the write operation.

Similarly, we can create a get hook for reading operations. Hooks can also have attributes, and they support full-fledged code blocks, not just simple expressions:

class Person
{
	public string $first;
	public string $last;
	public string $fullName {
		get {
			return "$this->first $this->last";
		}
		set(string $value) {
			[$this->first, $this->last] = explode(' ', $value, 2);
		}
	}
}

It's crucial to understand that all property access goes through hooks – even when $this->age is accessed within the Person class itself. The only place where you directly access the real variable is inside the hook itself.

Learning from History: The SmartObject Legacy

For Nette framework users, there's an interesting historical perspective. The framework offered similar functionality 17 years ago through SmartObject, which dramatically improved object handling at a time when PHP was significantly behind in this area.

I remember the initial wave of enthusiasm, with properties being used everywhere. This was followed by a complete reversal – avoiding them entirely. The reason was simple: there were no clear guidelines about when to use methods versus properties. However, today's native solution is in a different league entirely. Property hooks and asymmetric visibility aren't mere getter/setter replacements – they're powerful object design tools offering the same level of control as methods. This makes it much clearer when a property is truly the right choice.

Understanding Property Types: Backed vs Virtual

Take a look at this example and try to quickly answer:

  • Is $age write-only or read-write?
  • Is $adult read-only or read-write?
class Person
{
	public int $age = 0 {
		set => $value >= 0 ? $value : throw new InvalidArgumentException;
	}

	public bool $adult {
		get => $this->age >= 18;
	}
}

As mentioned earlier, $age is indeed a read-write property. But $adult is read-only!

This reveals the first major design challenge with property hooks: The property's signature doesn't indicate whether it's readable or writable!

The answer lies hidden in the implementation details of the hooks. Properties come in two flavors: backed (with actual memory storage) and virtual (which simulate property existence). The distinction between backed and virtual properties depends on whether we reference the property in its hook code.

A property is backed (has its own storage) if:

  • the hook body references itself with $this->propertyName
  • or it uses the shortened set syntax, which implies writing to $this->propertyName

In our example:

  • $age is backed because it uses the shortened set (automatically writing to $this->age)
  • $adult is virtual because none of its hooks reference $this->adult

While this is a clever implementation, it's problematic from a design perspective. Such fundamental information about property accessibility should be immediately clear from the API and signature, not buried in the implementation details.

Working Safely with References

PHP references allow two variables to share the same memory location. Changing one changes the other – like having two remote controls for the same TV. References are created using the & symbol.

If someone could obtain a reference to a property with a set hook, they could modify its value directly, bypassing all validation checks. Here's an example:

class Person
{
	public int $age = 0 {
		set => $value >= 0 ? $value : throw new InvalidArgumentException;
	}
}

$ref = &$person->age;    // Fatal error: Cannot take reference of property
$ref = -1;               // if this were allowed, it would bypass validation

For this reason, PHP prevents getting references to such properties entirely (specifically, backed properties with set hooks) and throws an error. This is the right approach – property hooks should guarantee that only valid values are stored, and references would compromise this guarantee.

Navigating Array Property Challenges

Objects with array properties allow direct element modification and addition:

class Person
{
	public array $phones = [];
}

$person = new Person;
$person->phones[] = '777 123 456';          // adds element to the end of array
$person->phones['bob'] = '777 123 456';     // adds element with specific key

This is where we encounter an interesting challenge with property hooks. Imagine creating a Person class with a list of phone numbers where we want to automatically trim whitespace:

class Person
{
	public array $phones = [] {
		set => array_map('trim', $value);
	}
}

$person = new Person;
$person->phones[] = '777 123 456';  // Throws Error: Indirect modification of Person::$phones is not allowed

Why doesn't this work? The operation $person->phones[] in PHP involves two steps:

  • First, it obtains a reference to the array via get
  • Then it adds a new value to the retrieved array

The set hook isn't invoked at all. More importantly, as discussed earlier, you can't get a reference to a backed property when it has a set hook. This explains the exception when trying to add a new number.

Even adding an addPhone() method that calls $this->phones[] = $phone; won't help, because all property access (even within the class) goes through hooks.

We might try another approach:

$phones = $person->phones;    // get the array
$phones[] = ' 777 123 456 ';  // add a number
$person->phones = $phones;    // save back

While this would work, imagine an array with thousands of numbers. Our set hook would need to trim() every number again, even though we only added one. This isn't efficient.

In my view, there's only one proper solution: recognizing that if an array needs special handling of its elements (like trimming spaces), this should be the array's responsibility – not the job of the object holding it. While we can't add new functionality to arrays directly, we can emulate it using an object implementing ArrayAccess:

class Phones implements ArrayAccess
{
	private array $data = [];

	public function __construct(array $data = [])
	{
		$this->data = array_map('trim', $data);
	}

	public function offsetSet(mixed $offset, mixed $value): void
	{
		$value = trim($value);
		if ($offset === null) {
			$this->data[] = $value;
		} else {
			$this->data[$offset] = $value;
		}
	}

	// implementation of offsetGet(), offsetExists(), offsetUnset() methods
}

class Person
{
	function __construct(
		public Phones $phones = new Phones,
	) {}
}

$person = new Person;
$person->phones[] = ' 777 123 456 ';  // number is stored trimmed

The trimming logic now resides where it belongs – in the object representing the phone number array.

We can leverage property hooks here for convenient array-to-object conversion, allowing arrays to be assigned to $person->phones:

class Person
{
	function __construct(
		public Phones $phones = new Phones {
			set(array|Phones $value) => is_array($value) ? new Phones($value) : $value;
		},
	) {}
}

$person = new Person;
$person->phones = ['  888 999 000  ', '777 888 999'];  // converts to Phones and trims

Notice that hooks work with promoted properties too.

There's also an alternative solution using virtual properties. Unlike backed properties, these don't use $this->propertyName in the hook body. While adding numbers via $person->phones[] = ... won't be possible, we can provide a method for this purpose:

class Person
{
	private array $_phones = []; // actual storage for phone numbers

	public array $phones {  // virtual property
		get => $this->_phones;
		set {
			$this->_phones = array_map('trim', $value);
		}
	}

	public function addPhone(string $phone): void
	{
		$this->_phones[] = trim($phone);
	}
}

$person = new Person;
$person->addPhone(' 777 123 456 ');  // stored trimmed

This approach continues using a standard array while keeping the trimming functionality within the Person class.

Inheritance Made Clear

Child classes can either add hooks to properties that previously had none or redefine existing hooks:

class Person
{
	public string $email;

	public int $age {
		set => $value >= 0
			? $value
			: throw new InvalidArgumentException('Age cannot be negative');
	}
}

class Employee extends Person
{
	// Adds hook to property that had none
	public string $email {
		set => strtolower($value);
	}

	// Extends existing hook with additional validation
	public int $age {
		set => $value <= 130
			? parent::$age::set($value)
			: throw new InvalidArgumentException('Age cannot be over 130'));
	}
}

The example shows Employee adding an email hook and extending age validation with an upper limit. To access the parent's hook implementation, we use the special syntax parent::$prop::set() or get(). This precisely expresses our intent – first referencing the parent's property, then its hook.

Hooks can be marked as final, preventing override by descendants. Similarly, entire properties can be marked final – preventing any form of override (adding hooks or changing visibility).

Properties in Interfaces and Abstract Classes

A groundbreaking addition is property support in interfaces and abstract classes. Previously, when creating an interface for named entities, we had to define getter and setter methods:

interface Named
{
	public function getName(): string;
	public function setName(string $name): void;
}

With property hooks, we can declare properties directly in interfaces, with asymmetric access – separately defining read and write requirements:

interface Named
{
	// implementing class must have a publicly readable name property
	public string $name { get; }
}

class Person implements Named
{
	public string $name;     // standard property
}

class Employee implements Named
{
	public string $name {
		get => $this->firstName . ' ' . $this->lastName;
	}
	private string $firstName;
	private string $lastName;
}

Notice an interesting detail – the Named interface requires a read-only property, while the implementing Person class provides a standard read-write property. This is valid because interfaces define minimum requirements.

Interface property declarations must use the public keyword, though it's redundant since interface members are inherently public. While I consider public unnecessary for methods, it's required for properties to maintain syntax consistency.

The declaration syntax is notably tied to hooks. While classes can use simple public string $name, interfaces require public string $name { get; set; } for read-write properties. This is actually beneficial – interfaces should clearly distinguish between read-only and read-write properties.

Abstract classes follow similar patterns but offer additional features. They can declare protected properties and provide default hook implementations:

abstract class Person
{
	abstract public string $name { get; }
	abstract protected int $age { get; set; }

	// Providing default set hook implementation
	abstract public string $email {
		get;
		set => Nette\Utils\Validators::isEmail($value)
			? $value
			: throw new InvalidArgumentException('Invalid email');
	}
}

Covariant/Contravariant Properties

Properties in interfaces and abstract classes support covariance and contravariance. Properties with only get hooks can be covariant (returning more specific types in descendants). Properties with only set hooks can be contravariant (accepting more general types):

class Animal {}
class Dog extends Animal {}

interface AnimalHolder
{
	public Animal $pet { get; }
}

class DogHolder implements AnimalHolder
{
	// Returns more specific type - perfectly valid
	public Dog $pet { get; }
}

Properties with both get and set hooks must maintain their exact type.

Understanding Asymmetric Visibility

Another major feature complementing property hooks is asymmetric visibility – the ability to set different access levels for reading and writing properties. While less complex than hooks, asymmetric visibility elegantly solves many situations where we previously needed getters and setters. Though hooks provide more power through custom logic, asymmetric visibility offers a cleaner solution for the common “public read, private write” pattern.

Consider a Person class where we want the birth date readable by anyone but modifiable only by the class itself:

class Person
{
	public private(set) DateTimeImmutable $dateOfBirth;
}

The first modifier public controls read access, while private(set) controls write access. Since public reading is the default, we can simplify to:

class Person
{
	private(set) DateTimeImmutable $dateOfBirth;
}

A logical rule applies: write visibility cannot exceed read visibility. Therefore, protected public(set) is invalid.

Regarding inheritance, PHP allows descendants to either maintain property visibility or broaden it from protected to public. This extends to asymmetric visibility. For example, a descendant can upgrade write access from protected to public:

class Person
{
	public protected(set) string $name;
}

class Employee extends Person
{
	// can broaden write access
	public public(set) string $name;
}

Properties with private(set) are automatically final – logical since private write access means no one (including descendants) should modify the property. Therefore, descendants cannot redefine such properties.

While hooks control “what happens” during property access, asymmetric visibility determines “who can do it”. These complementary concepts work together effectively:

class Person
{
	private(set) DateTimeImmutable $birthDate {
		set => $value > new DateTimeImmutable
			? throw new InvalidArgumentException('Birth date cannot be in future');
		   	: $value;
	}
}

This property is publicly readable, privately writable, and validates that dates aren't in the future.

Solving the Array Challenge with Asymmetric Visibility

Remember our phone numbers example? Asymmetric visibility offers another elegant solution:

class Person
{
	private(set) array $phones = [];

	public function addPhone(string $phone): void
	{
		$this->phones[] = trim($phone);
	}
}

$person = new Person;
var_dump($person->phones);  // OK: reading is allowed
$person->addPhone('...');   // OK: can add numbers through method
$person->phones = [];       // ERROR: direct array assignment not allowed

Here, $person->phones is read-only – you can't write to it directly. Instead, we provide a method for adding numbers.

For completeness, note that properties with restricted write access don't allow external references:

$ref = &$person->phones;    // Fatal error: Cannot take reference

References work only from scopes where the property is writable.

Let's summarize our options for handling arrays in properties:

  • Use an array-simulating object (adds overhead but provides complete control)
  • Use a backed property with hooks (prevents direct array modifications)
  • Use a virtual property with private storage (requires auxiliary methods)
  • Use asymmetric visibility (moves manipulation logic to methods)

Each approach has its trade-offs – choose based on your specific needs.

Rethinking Readonly and Asymmetric Visibility

The readonly modifier actually combines two features: it prevents multiple writes and also restricts writing to private. This second aspect always seemed overly restrictive. Why should read-only properties automatically be private for writing? Often, we want descendants to modify them exactly once.

PHP 8.4 addresses this by making readonly properties protected(set) by default – allowing descendant classes to modify them. We can still customize write visibility if needed:

class Person
{
	// read-only, writable only within the class (pre-8.4 behavior)
	public private(set) readonly string $name;

	// read-only, writable by descendants (new 8.4 default)
	public readonly string $dateOfBirth;

	// read-only, publicly writable
	public public(set) readonly string $id;
}

The Terminology Challenge

Have you noticed the inconsistent terminology?

  • We discuss reading and writing properties
  • We have the readonly modifier
  • We use @property-read and @property-write annotations
  • Yet hooks, interfaces, and asymmetric visibility use get/set

Wouldn't read and write make more sense? While hooks maintain consistency with magic methods __get/__set, the terms read/write would be more intuitive.

The use of set makes some sense for hooks (describing an action), but private(set) feels forced. Asymmetric visibility addresses “who can do it” rather than “what happens” – making write more appropriate. Consider:

class Person
{
	private(write) string $name;   // this would be more intuitive
	private(set) string $name;     // this is what we have
}

PHP seems to have prioritized syntactic consistency between hooks and asymmetric visibility over semantic alignment with existing language concepts.

PHP's New Chapter

For years, PHP developers followed one recommended pattern: private properties with getter and setter methods. Public properties were problematic because:

  • they offered no value control
  • as part of the public API, any modification meant breaking changes
  • they couldn't be declared in interfaces

Anyone aiming for proper design patterns, interfaces, and dependency injection had to use getters and setters. These were our only tools for controlling object data access.

PHP 8.4 changes everything. Property hooks and asymmetric visibility provide method-level control over properties. We can confidently use properties in public APIs because:

  • we can add validation or transformation anytime
  • we can control access levels
  • we can declare them in interfaces

You might view property hooks as elegant getter/setter syntax, or see getters/setters as unnecessary boilerplate.

Drawing from my Nette framework experience – which offered similar features 17 years ago – I can attest to their appeal. Compare these approaches:

// traditional style
$this->getUser()->getIdentity()->getName()

// property style
$this->user->identity->name

The property approach isn't just shorter and clearer – it's addictively elegant.

Some might argue that direct property access violates object-oriented principles, preferring to ask objects to perform operations rather than expose data (Tell-Don't-Ask principle). This concern is valid for behavior-rich objects implementing business logic. However, for data transfer objects, value objects, or configuration objects, direct data access makes perfect sense.

A new challenge emerges: existing libraries using getters/setters might not want to introduce properties mid-stream. This would create inconsistent APIs where users must guess whether to use methods or properties. Sometimes maintaining the established pattern makes more sense.

We'll likely see new coding styles emerge. Some projects will stick with getters/setters, others will embrace properties.

Property naming deserves attention too. While boolean method prefixes like is and has work well (isEnabled(), hasComments()), they feel redundant for properties. Better to use simple nouns or adjectives ($enabled, $commented).

Making the Choice: Properties vs Methods

Starting a new project? Here's when to use each, based on lessons from languages like C# and Kotlin that have long experience with properties:

  1. Properties excel for:
    • value objects and data transfer objects
    • configuration objects
    • data-centric entities
    • computed values derived from other properties
    • cases needing basic validation or transformation
  2. Methods work better for:
    • operations affecting multiple properties
    • operations with side effects (logging, notifications)
    • actions (save, send, calculate…)
    • complex validation or business logic
    • operations with multiple failure modes
    • cases benefiting from fluent interfaces

These guidelines suggest one key principle: properties shouldn't hide complex processes or side effects. Their behavior should match what you'd expect from variable access.

However… consider the DOM API in browsers. The simple-looking element.innerHTML = '...' triggers complex processes – HTML parsing, DOM tree creation, layout recalculation, reflow, repaint… Yet developers find this perfectly natural.

Perhaps the boundaries aren't so rigid. What matters most is whether an operation _conceptually_ represents property access. If it does, use a property, even with complex underlying logic. If not, use a method, even for simple operations.

16 hours ago in section PHP | blog written by David Grudl | back to top

You might be interested in

Leave a comment

Text of the comment
Contact

(kvůli gravataru)



*kurzíva* **tučné** "odkaz":http://example.com /--php phpkod(); \--

phpFashion © 2004, 2024 David Grudl | o blogu

Ukázky zdrojových kódů smíte používat s uvedením autora a URL tohoto webu bez dalších omezení.