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 shortenedset
(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:
- 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
- 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.
Leave a comment