[PHP-DEV] [RFC] Readonly property hooks

On Thu, Jul 17, 2025, at 15:10, Eric Norris wrote:

On Thu, Jul 17, 2025 at 3:31 AM Rob Landers <rob@bottled.codes> wrote:

On Tue, Jul 15, 2025, at 19:27, Nicolas Grekas wrote:

Le lun. 14 juil. 2025 à 15:41, Larry Garfield <larry@garfieldtech.com> a écrit :

On Sun, Jul 13, 2025, at 6:28 PM, Ilija Tovilo wrote:

Hi Nick

On Fri, Jul 11, 2025 at 6:31 AM Nick <php@nicksdot.dev> wrote:

On 8. Jun 2025, at 11:16, Larry Garfield <larry@garfieldtech.com> wrote:

https://wiki.php.net/rfc/readonly_hooks

To not get this buried in individual answers to others:

I came up with two alternative implementations which cache the computed get hook value.
One leverages separate cache properties, the other writes directly to the backing store.

Links to the alternative branches can be found in the description of the original PR.
https://github.com/php/php-src/pull/18757

I am not a fan of the caching approach. The implementation draft for
this approach [^1] works by storing the assigned value in the property
slot, and replacing it with the value returned from get one called for
the first time. One of the issues here is that the backing value is
observable without calling get. For example:

class C {
public public(set) readonly string $prop {
get => strtoupper($this->prop);
}
}
$c = new C();
$c->prop = 'foo';
var_dump(((array)$c)['prop']); // foo
$c->prop;
var_dump(((array)$c)['prop']); // FOO

Here we can see that the underlying value changes, despite the
readonly declaration. This is especially problematic for things like
[un]serialize(), where calling serialize() before or after accessing
the property will change which underlying value is serialized. Even
worse, we don’t actually know whether an unserialized property has
already called the get hook.

class C {
public public(set) readonly int $prop {
get => $this->prop + 1;
}
}
$c = new C();
$c->prop = 1;
$s1 = serialize($c);
$c->prop;
$s2 = serialize($c);
var_dump(unserialize($s1)->prop); // int(2)
var_dump(unserialize($s2)->prop); // int(3)

Currently, get is always called after unserialize(). There may be
similar issues for __clone().

For readable and writable properties, the straight-forward solution is
to move the logic to set.

class C {
public public(set) readonly int $prop {
set => $value + 1;
}
}

This is slightly differently, semantically, in that it executes any
potential side-effects on write rather than read, which seems
reasonable. This also avoids the implicit mutation mentioned
previously. At least in these cases, disallowing readonly + get seems
reasonable to me. I will say that this doesn’t solve all get+set
cases. For example, proxies. Hopefully, lazy objects can mostly bridge
this gap.

Another case is lazy getters.

class C {
public readonly int $magicNumber {
get => expensiveComputation();
}
}

This does not seem to work in the current implementation:

Fatal error: Hooked virtual properties cannot be declared readonly

I presume it would be possible to fix this, e.g. by using readonly as
a marker to add a backing value to the property. I’m personally not
too fond of making the rules on which properties are backed more
complicated, as this is already a common cause for confusion. I also
fundamentally don’t like that readonly changes whether get is called.
Currently, if hooks are present, they are called. This adds more
special cases to an already complex feature.

To me it seems the primary motivator for this RFC are readonly
classes, i.e. to prevent the addition of hooks from breaking readonly
classes. However, as lazy-getters are de-facto read-only, given they
are only writable from the extremely narrow scope of the hook itself,
the modifier doesn’t do much. Maybe an easier solution would be to
provide an opt-out of readonly.

Thanks, Ilija. You expressed my concerns as well. And yes, in practice, readonly classes over-reaching is the main use case; if you’re marking individual properties readonly, then just don’t mark the one that has a hook on it (use aviz if needed) and there’s no issue.

Perhaps we’re thinking about this the wrong way, though? So far we’ve talked as though readonly makes the property write-once. But… what if we think of it as applying to the field, aka the backing value?

So readonly doesn’t limit calling the get hook, or even the set hook, multiple times. Only writing to the actual value in the object table. That gives the exact same set of guarantees that a getX()/setX() method would give. The methods can be called any number of times, but the stored value can only be written once.

That would allow conditional set hooks, conditional gets, caching gets (like we already have with ??=), and so on. The mental model is simple and easy to explain/document. The behavior is the same as with methods. But the identity of the stored value would be consistent.

It would not guarantee $foo->bar === $foo->bar in all cases (though that would likely hold in the 99% case in practice), but then, $foo->getBar() === $foo->getBar() has never been guaranteed either.

Would that way of looking at it be acceptable to folks?

It does to me: readonly applies to the backed property, then hooks add behavior as see fit. This is especially useful to intercept accesses to said properties. Without readonly hooks, designing an abstract API that uses readonly properties is a risky decision since it blocks any (future) implementation that needs this interception capability. As a forward-thinking author, one currently has two choices: not using readonly properties in abstract APIs, or falling back to using getter/setters. That’s a design failure for hooks IMHO. I’m glad this RFC exists to fill this gap.

Nicolas

To add to this, as I just mentioned on the Records thread, it would be good to get hooks on readonly objects. With the new clone(), there is no way to rely on validation in constructors. The most robust validation in 8.5 can only be done via set/get hooks, but these hooks are not available on readonly classes. This means that it is remarkably easy to “break” objects that do constructor validation + use public(set) – or use clone() in inherited objects instead of the parent constructor. In my experience, readonly objects typically only do constructor validation (DRY).

(shoot, double post, sorry Rob)

I’m not sure I follow - do you actually need both set and get
hooks for validation? I would think only set hooks would be
necessary, and I don’t yet think I’ve seen an objection to set hooks
for readonly.

It depends… for example, you might have an isValid property which computes whether or not the object is valid, or in the case of lazy properties, detecting an invalid state there. You also might put validation in getters because you’re building up the object over several lines, thus it might only be in a partially valid state during construction:

readonly class User {
public public(set) $first_name;
public public(set) $last_name;
public $name { get => implode(’ ', [$this->first_name, $this->last_name]) }
}

$user->first_name = “Rob”
echo $user->name; // oops

Or something. In this case, we can rely on the “uninitialized property” exception to be raised if it isn’t yet fully valid, but you might want to throw your own exception.

— Rob

On Sat, Jun 7, 2025, at 11:16 PM, Larry Garfield wrote:

As Nick has graciously provided an implementation, we would like to
open discussion on this very small RFC to allow `readonly` on backed
properties even if they have a hook defined.

PHP: rfc:readonly_hooks

"Very small" never lasts, does it... :slight_smile:

Given the lack of consensus both here and in off-list discussions on how to handle get hooks, we have done the following:

* Split the RFC into two sections, one for get, one for set.
* Expanded and refined the examples for both. The implementation is still the original, however.
* Split the vote into two: one for allowing readonly get hooks, one for readonly set hooks.

We will start the vote sometime this weekend, most likely, unless some major feedback appears before then, and let the chips fall where they may.

--Larry Garfield

Hi

Apologies for the belated reply. I was busy with getting my own implementation wrapped up and the thread was so active that I had troubles keeping up.

On 7/11/25 06:20, Nick wrote:

Personally, I would really like to have `get` hooks on readonly properties. Please consider something like this:

readonly class Foo
{
     public function __construct(
         public Output $style,
         public string $some {
             get => Output::One === $this->style ? ucfirst($this->some) : strtoupper($this->some);
             set => '' !== $value ? $value : throw new \Exception();
         }
     ) {}
}

Easy-to-digest one-liners. Concerns remain separated. Set takes care of validation, get formats.

I respectfully disagree on the "easy-to-digest" part. A 98 character line containing logic is not easy to digest.

If `get` would not be allowed, we couldn’t do such an obvious thing. For what reason?

In *this specific instance* the `get` hook would not violate my expectations, but this is not true in general.

Instead we would need to delegate formatting to the `set` hook which is messy.

Running formatting for every access is messy. And it's messy to needlessly use hooks for something that can just be constructor logic.

Since `$some` is *always* assigned when running the constructor, this can just be:

     readonly class Foo {
         public string $some;

         public function __construct(
             public Output $style,
             string $some,
         ) {
             if ($some === '') {
                 throw new \Exception();
             }

             $this->some = match ($style) {
                 Output::One => ucfirst($some),
                 default => strtoupper($some),
             };
         }
     }

Making `Foo` a plain old data class without any behavior after construction and with very obvious control flow within the constructor. This results in both more efficient and easier to reason about code.

Best regards
Tim Düsterhus

Hi

On 7/9/25 19:58, Claude Pache wrote:

I hear you, but I still struggle to fully grasp the issue. It’s genuinely hard for me to come up with a real-world example that actually makes sense.
Everything I’ve seen so far, including the RFC example and what I tried myself (I gave it an honest shot), feels either very theoretical or entirely intentional, and thus perfectly logical in its outcome.

In one of your previous mails you brought up an example that requires calling a class method (read: intentionally changing class state), which would result in a non-consistent value being returned when calling the same property more than once. I get it. But what if the user wants exactly that in their `readonly` class?

Yes, it’s mostly theoretical, but it is good to base language design on sound theory.

But here is a potential practical issue. A random user wants to extend a class from a third-party library, but they are annoyed that a given property is readonly. Now, using a get hook, it is trivial for them to cheat and to work around what it perceives as an undue limitation, not realising that it may break assumptions made elsewhere in the library. — Indeed, I don’t trust users and want to protect them against themselves.

Full agreement on Claude's entire email, but particularly this part. Users have expectations from seeing the `readonly` keyword and adding the `readonly` keyword is an intentional choice by the class author. The language should not allow making it easy to violate these expectations (by accident). This is no different from the language making sure for you that you may only return values of an appropriate type from a function having a return type. The `readonly` keyword is part of your public API just like the types are.

Best regards
Tim Düsterhus

Hi

On 7/9/25 16:05, Larry Garfield wrote:

1. `readonly` bills itself as immutability, but it fundamentally is not. There are at least two loopholes: __get and a mutable object saved to a property. So while it offering immutability guarantees is nice in theory, it's simply not true in practice. `readonly` has always been misnamed; it should really be `writeonce`, because that's all it is. (Once again, this is likely the most poorly designed feature we've added in many years.)

No, readonly is readonly, not writeonce. Stop trying to redefine readonly as writeonce to justify bad design decisions.

Readonly guarantees that once I successfully read from a property that I'll get the same thing out on subsequent reads and I consider this to be valuable and strongly disagree on the "most poorly designed feature" bit.

Yes, I understand that __get() currently is an exception to that guarantee, but that does not mean that further exceptions should be added to water down readonly into something that is completely useless.

2. In 8.4, if a class is marked `readonly`, you basically forbid it from having any hooks of any kind, even though you absolutely can honor the write-once-ness of the properties while still having hooks. And that applies to child classes, too, because `readonly`-ness inherits. So adding a single hook means you have to move the readonly to all the other properties individually, which if inheritance is involved you cannot do.

The RFC aims to address point 2 in a way that still respects point 1, but only point 1 as it actually is (write-once), not as we wish it to be (immutability).

Readonly is immutability of values (or in other words immutability of identity). For objects this means immutability of the object handle, for other types this means actual immutability.

I also feel compelled to mention at this point that the commonly repeated statement of "Objects are passed by reference" is incorrect. It's that "the object handle is passed by value". And then it's fully consistent with how readonly works as of now.

* set hooks for validation, which don't impact writeonce-ness. I think everyone seems on board with that.

Yes, allowing set hooks for readonly properties seems sound to me.

* Lazy computed properties. I use these a ton, even for internal caching purposes. 99% of the time I cache them because my objects are practically immutable, and $this->foo ??= whatever is an easy enough pattern. (If they're not cached then it would be a virtual property, which we're not dealing with for now.) As long as you're caching it in that fashion, the write-once-ness still ends up respected.

Honestly, Nick tried to come up with examples yesterday while we were talking that would not fit into one of those two categories, and for every one of them my answer was "if your code is already that badly designed, there's nothing we can do for you." :slight_smile:

It's nice to hear that there are no other usecases for hooks on readonly properties, since this means that we can just allow the 'set' hook and add an 'init' hook for the lazy computation use-case without needing to violate the semantics of `readonly` by allowing a `get` hook.

An init hook would be clearer, certainly, though it also has its own edge cases. Can you set something that has an init hook? What happens if there's both a get and init hook? These probably have answers that could be sorted out, but that's a different question from "why the <censored> does a readonly class forbid me using even rudimentary hooks???"

Not clearer. It would be the only thing that is semantically sound. While it certainly needs careful consideration of semantics to ensure there are no edge cases, figuring this out should be much easier than intentionally introducing edge cases via a get hook.

As to your questions: The init hook is triggered when reading from a property that is in the uninitialized state. The return value of the hook is stored in the property and returned as the result of the read operation. Having an init hook implies the property is non-virtual.

- Yes, you can set something that has an init hook. Setting means that the property will no longer be uninitialized, which means that the init hook will no longer be called.
- If there is both a get and an init hook, the init hook will be called when the backing store is uninitialized. The result of the init hook will then also go through the get hook. On subsequent reads only the get hook will be called.

Best regards
Tim Düsterhus

Hi

see also my previous reply to your email where you initially mentioned init hooks.

On 7/9/25 19:11, Larry Garfield wrote:

Can an init hook reference itself, the way get and set can?

If the init hook references its own property you will get endless recursion.

If there is both an init and set hook, what happens? Is it different if set reads from itself than if it writes to itself?

The first bit I answered in my previous email. If set reads from itself and the property is uninitialized, it will invoke the init hook.

Should combining init and set be forbidden as confusing?

No.

Can you have both an init hook and a get hook? What happens then?

See previous email.

Repeat all of the above on readonly properties.

No difference, except that the 'get' hook should not be allowed there.

there is a reasonable solution right in front of us that is trivial to implement.

I disagree on the "reasonable" part.

Best regards
Tim Düsterhus

Hi

On 7/10/25 17:34, Larry Garfield wrote:

Nick previously suggested having the get-hook's first return value cached; it would still be subsequently called, so any side effects would still happen (though I don't know why you'd want side effects), but only the first returned value would ever get returned. Would anyone find that acceptable? (In the typical case, it would be the same as the current $this->foo ??= compute() pattern, just with an extra cache entry.)

I'm seeing this proposal has already been dropped, but to spell it out explicitly:

No, I would not find it acceptable for side effects to happen once again, but the return value ignored.

And when dropping the "side effects run once again" part, you arrive at an 'init' hook, which I would be in favor of, since it would provide semantics that are sound with regard to user expectations.

"Cached get" is just init with extra confusion.

Best regards
Tim Düsterhus

On Fri, 18 Jul 2025, 15:16 Tim Düsterhus, <tim@bastelstu.be> wrote:

Hi

On 7/9/25 16:05, Larry Garfield wrote:

  1. readonly bills itself as immutability, but it fundamentally is not. There are at least two loopholes: __get and a mutable object saved to a property. So while it offering immutability guarantees is nice in theory, it’s simply not true in practice. readonly has always been misnamed; it should really be writeonce, because that’s all it is. (Once again, this is likely the most poorly designed feature we’ve added in many years.)

No, readonly is readonly, not writeonce. Stop trying to redefine
readonly as writeonce to justify bad design decisions.

Readonly guarantees that once I successfully read from a property that
I’ll get the same thing out on subsequent reads and I consider this to
be valuable and strongly disagree on the “most poorly designed feature” bit.

Yes, I understand that __get() currently is an exception to that
guarantee, but that does not mean that further exceptions should be
added to water down readonly into something that is completely useless.

  1. In 8.4, if a class is marked readonly, you basically forbid it from having any hooks of any kind, even though you absolutely can honor the write-once-ness of the properties while still having hooks. And that applies to child classes, too, because readonly-ness inherits. So adding a single hook means you have to move the readonly to all the other properties individually, which if inheritance is involved you cannot do.

The RFC aims to address point 2 in a way that still respects point 1, but only point 1 as it actually is (write-once), not as we wish it to be (immutability).

Readonly is immutability of values (or in other words immutability of
identity). For objects this means immutability of the object handle, for
other types this means actual immutability.

I also feel compelled to mention at this point that the commonly
repeated statement of “Objects are passed by reference” is incorrect.
It’s that “the object handle is passed by value”. And then it’s fully
consistent with how readonly works as of now.

  • set hooks for validation, which don’t impact writeonce-ness. I think everyone seems on board with that.

Yes, allowing set hooks for readonly properties seems sound to me.

  • Lazy computed properties. I use these a ton, even for internal caching purposes. 99% of the time I cache them because my objects are practically immutable, and $this->foo ??= whatever is an easy enough pattern. (If they’re not cached then it would be a virtual property, which we’re not dealing with for now.) As long as you’re caching it in that fashion, the write-once-ness still ends up respected.

Honestly, Nick tried to come up with examples yesterday while we were talking that would not fit into one of those two categories, and for every one of them my answer was “if your code is already that badly designed, there’s nothing we can do for you.” :slight_smile:

It’s nice to hear that there are no other usecases for hooks on readonly
properties, since this means that we can just allow the ‘set’ hook and
add an ‘init’ hook for the lazy computation use-case without needing to
violate the semantics of readonly by allowing a get hook.

An init hook would be clearer, certainly, though it also has its own edge cases. Can you set something that has an init hook? What happens if there’s both a get and init hook? These probably have answers that could be sorted out, but that’s a different question from “why the does a readonly class forbid me using even rudimentary hooks???”

Not clearer. It would be the only thing that is semantically sound.
While it certainly needs careful consideration of semantics to ensure
there are no edge cases, figuring this out should be much easier than
intentionally introducing edge cases via a get hook.

As to your questions: The init hook is triggered when reading from a
property that is in the uninitialized state. The return value of the
hook is stored in the property and returned as the result of the read
operation. Having an init hook implies the property is non-virtual.

  • Yes, you can set something that has an init hook. Setting means that
    the property will no longer be uninitialized, which means that the init
    hook will no longer be called.
  • If there is both a get and an init hook, the init hook will be called
    when the backing store is uninitialized. The result of the init hook
    will then also go through the get hook. On subsequent reads only the get
    hook will be called.

Best regards
Tim Düsterhus

Hi Tim,

The problem with allowing only set hooks is that readonly class won’t be compatible with hooks, I think that is one of the main motivations behind this RFC.

Faizan Akram Dar
faizanakram.me

Hi

On 7/18/25 16:25, Faizan Akram Dar wrote:

The problem with allowing only set hooks is that readonly class won't be
compatible with hooks, I think that is one of the main motivations behind
this RFC.

Yes. The point is that the semantics users expect from `readonly` are fundamentally incompatible with a get hook that could return arbitrarily changing values whenever you read from a property.

Proposing to change this is like proposing an RFC that allows storing a `string` in a property with type `array`. It would be a massive break in user expectations for a feature that existed since 4 PHP versions (in case of readonly).

Best regards
Tim Düsterhus

Just a heads up: I also plan to vote “no” on this RFC because the expectation with readonly is that there is no kind of interference or lazy initialization anyway.

Now that lazy proxies have landed into core, there is also no need for __get hacks anymore.

···

Marco Pivetta

https://mastodon.social/@ocramius

https://ocramius.github.io/

Hi

On 7/14/25 15:38, Larry Garfield wrote:

Thanks, Ilija. You expressed my concerns as well. And yes, in practice, readonly classes over-reaching is the main use case; if you're marking individual properties readonly, then just don't mark the one that has a hook on it (use aviz if needed) and there's no issue.

A readonly class is not just a convenience shortcut to mark each individual property as readonly. It has important semantics of its own, because it forces child classes to also be readonly. And even for final classes it communicates to the user that "I won't be adding non-readonly properties to the class".

Marking a class as readonly must therefore be a deliberate decision, since it affects the public API of your class and in turn also user expectations.

Perhaps we're thinking about this the wrong way, though? So far we've talked as though readonly makes the property write-once. But... what if we think of it as applying to the field, aka the backing value?

I think of readonly from the view of the public API surface of an object. The property hooks RFC was very explicit in that property hooks are intended to be “transparent to the user” and can be added without breaking the public API. In other words: Whether or not a property is implemented using a hook should be considered an implementation detail and as a user of a class I do not care whether there is a backing value or not.

So readonly doesn't limit calling the get hook, or even the set hook, multiple times. Only writing to the actual value in the object table. That gives the exact same set of guarantees that a getX()/setX() method would give. The methods can be called any number of times, but the stored value can only be written once.

As a user of a class the "backing table" is mostly inaccessible to me when interacting with objects. It's only exposed via var_dump() and serialize(), the former of which is a debug functionality and the output of latter not something I must touch.

It would not guarantee $foo->bar === $foo->bar in all cases (though that would likely hold in the 99% case in practice), but then, $foo->getBar() === $foo->getBar() has never been guaranteed either.

Properties and methods are something different. For methods there a reasonable expectation that *behavior* is associated with them, for properties there is not.

A 99% case is not sufficient for me to rely on when there's explicit communication by the class author that I may rely on properties not suddenly changing.

Best regards
Tim Düsterhus

On Fri, Jul 18, 2025, at 17:25, Tim Düsterhus wrote:

Hi

On 7/14/25 15:38, Larry Garfield wrote:

Thanks, Ilija. You expressed my concerns as well. And yes, in practice, readonly classes over-reaching is the main use case; if you’re marking individual properties readonly, then just don’t mark the one that has a hook on it (use aviz if needed) and there’s no issue.

A readonly class is not just a convenience shortcut to mark each
individual property as readonly. It has important semantics of its own,
because it forces child classes to also be readonly. And even for final
classes it communicates to the user that “I won’t be adding non-readonly
properties to the class”.

Wasn’t that the entire point of readonly classes? Because it was painful to write readonly for every property. Then if a property is readonly, the inherited property is also readonly, so, by extension: a class extending a readonly class is also readonly.

There’s no “communication” here; just logic.

Marking a class as readonly must therefore be a deliberate decision,
since it affects the public API of your class and in turn also user
expectations.

Not really. I can remove the readonly designation and manually mark every property as readonly. The behavior of the class doesn’t magically change. Or, at least, I hope it doesn’t.

Perhaps we’re thinking about this the wrong way, though? So far we’ve talked as though readonly makes the property write-once. But… what if we think of it as applying to the field, aka the backing value?

I think of readonly from the view of the public API surface of an
object. The property hooks RFC was very explicit in that property hooks
are intended to be “transparent to the user” and can be added without
breaking the public API. In other words: Whether or not a property is
implemented using a hook should be considered an implementation detail
and as a user of a class I do not care whether there is a backing value
or not.

So readonly doesn’t limit calling the get hook, or even the set hook, multiple times. Only writing to the actual value in the object table. That gives the exact same set of guarantees that a getX()/setX() method would give. The methods can be called any number of times, but the stored value can only be written once.

As a user of a class the “backing table” is mostly inaccessible to me
when interacting with objects. It’s only exposed via var_dump() and
serialize(), the former of which is a debug functionality and the output
of latter not something I must touch.

It would not guarantee $foo->bar === $foo->bar in all cases (though that would likely hold in the 99% case in practice), but then, $foo->getBar() === $foo->getBar() has never been guaranteed either.

Properties and methods are something different. For methods there a
reasonable expectation that behavior is associated with them, for
properties there is not.

Unless I missed something. Hooks are fancy methods? There is nothing intrinsic about object properties. There is nothing that says two calls to the same property’s getters are going to result in the same values. There is asynchronous php, declare ticks, etc. especially in the case of globals, there is no guarantee you even have the same object. At the end of the day, it is up to the programmer building that system / program to provide those guarantees— not the language.

A 99% case is not sufficient for me to rely on when there’s explicit
communication by the class author that I may rely on properties not
suddenly changing.

Best regards
Tim Düsterhus

— Rob

On Fri, Jul 18, 2025, at 9:50 AM, Marco Pivetta wrote:

On Fri, 18 Jul 2025 at 16:43, Tim Düsterhus <tim@bastelstu.be> wrote:

On 7/18/25 16:25, Faizan Akram Dar wrote:
> The problem with allowing only set hooks is that readonly class won't be
> compatible with hooks, I think that is one of the main motivations behind
> this RFC.

Yes. The point is that the semantics users expect from `readonly` are
fundamentally incompatible with a get hook that could return arbitrarily
changing values whenever you read from a property.

Just a heads up: I also plan to vote "no" on this RFC because the
expectation with `readonly` is that there is no kind of interference or
lazy initialization anyway.

Now that lazy proxies have landed into core, there is also no need for
`__get` hacks anymore.

Even on set hooks, which do not violate any of the interpretations of what readonly "means" floating about? As the RFC notes, that's now a necessary validation step with the improved clone() on its way.

--Larry Garfield

Hi

On 7/17/25 18:26, Larry Garfield wrote:

Given the lack of consensus both here and in off-list discussions on how to handle get hooks, we have done the following:

* Split the RFC into two sections, one for get, one for set.
* Expanded and refined the examples for both. The implementation is still the original, however.
* Split the vote into two: one for allowing readonly get hooks, one for readonly set hooks.

We will start the vote sometime this weekend, most likely, unless some major feedback appears before then, and let the chips fall where they may.

After working through (most of) the discussion, I've now taken a look at the updated RFC. I have the following remarks:

1.

It is really “write-once”, which is not the same as immutable (as shown above). But there's no reason that “write-once” need be incompatible with hooks.

This is a strawman argumentation, as I've outlined in my previous emails, calling readonly "write-once" is wrong. It is a reasonable user expectation to always get the identical value when reading a value that may only be set once. By calling it "write-once" you are trying to shift the focus to the write operation, which is totally irrelevant for user expectations when interacting with readonly properties. Especially for expectations of users that just *use* a class rather than writing one.

2.

The concern would only appear if someone is deliberately doing something non-stable

Or if someone accidentally calls a (deep) function chain that is non-pure.

3.

Or, for a more real-world and larger example, PHP 8.4 requires this:

This is false. You are perfectly able to write this in PHP 8.4:

     final readonly class Entry
     {
         public readonly $terms;

         public function __construct(
             public string $word,
             public string $slug,
             array $terms,
         ) {
             $this->terms = $this->upcastTerms($terms);
         }

         private function upcastTerms(array $terms): array
         {
             $upcast = static fn (Term|array $term): Term
                 => $term instanceof Term ? $term : new Term(...$term);
             return array_map($upcast, $value)
         }
     }

In no way do you need to use a property hook.

4.

// New code in 8.5:
$p = new PositivePoint(3, 4);
$p2 = clone($p, ['x' => -10]);

This is not legal code in PHP 8.5. Clone-with respects visibility and since your asymmetric visibility RFC included the change, you are probably aware that `readonly` implies `protected(set)`.

5.

but are now necessary to ensure that invariants are enforced.

And therefore with PHP 8.5 hooks are not necessary to enforce invariants, except in the rare case where a `public(set) readonly` property is used.

6.

So no guarantees are softened by this RFC.

Yes, they are. Unless `__get()` is implemented on a class (which is explicitly visible as part of the public API), readonly guarantees the immutability of identity.

7.

While that is an interesting idea that has been floated a few times, it has enough complexities and edge cases of its own to address that we feel it is out of scope.

While it certainly is your right as the RFC authors to consider certain things out of scope for an RFC, I strongly oppose the notion of shipping something that is strictly inferior and comes with obvious semantic issues due to perceived complexity of another solution and then following up with the proper solution that has already been identified. As I've outlined in my previous emails, I found defining semantics for an 'init' hook straight-forward when looking at how PHP works as of today.

8.

However, this RFC is in no way incompatible with adding an init hook in the future should it be proposed.

This is true, but as I've mentioned before, an 'init' hook would enable the same use cases without bringing along issues. So it really should be "one of them, but not both" (with "one of them" being the init hook).

--------

After reading through the discussion, it seems the only argument against the 'init' hook is perceived complexity. It is not at all clear to me why this means that we must now rush something with clear issues into PHP 8.5.

Best regards
Tim Düsterhus

On Fri, Jul 18, 2025 at 12:01 PM Rob Landers <rob@bottled.codes> wrote:

On Fri, Jul 18, 2025, at 17:25, Tim Düsterhus wrote:

Hi

On 7/14/25 15:38, Larry Garfield wrote:
> Thanks, Ilija. You expressed my concerns as well. And yes, in practice, readonly classes over-reaching is the main use case; if you're marking individual properties readonly, then just don't mark the one that has a hook on it (use aviz if needed) and there's no issue.

A readonly class is not just a convenience shortcut to mark each
individual property as readonly. It has important semantics of its own,
because it forces child classes to also be readonly. And even for final
classes it communicates to the user that "I won't be adding non-readonly
properties to the class".

Wasn’t that the entire point of readonly classes? Because it was painful to write readonly for every property. Then if a property is readonly, the inherited property is also readonly, so, by extension: a class extending a readonly class is also readonly.

There’s no “communication” here; just logic.

Marking a class as readonly must therefore be a deliberate decision,
since it affects the public API of your class and in turn also user
expectations.

Not really. I can remove the readonly designation and manually mark every property as readonly. The behavior of the class doesn’t magically change. Or, at least, I hope it doesn’t.

> Perhaps we're thinking about this the wrong way, though? So far we've talked as though readonly makes the property write-once. But... what if we think of it as applying to the field, aka the backing value?

I think of readonly from the view of the public API surface of an
object. The property hooks RFC was very explicit in that property hooks
are intended to be “transparent to the user” and can be added without
breaking the public API. In other words: Whether or not a property is
implemented using a hook should be considered an implementation detail
and as a user of a class I do not care whether there is a backing value
or not.

> So readonly doesn't limit calling the get hook, or even the set hook, multiple times. Only writing to the actual value in the object table. That gives the exact same set of guarantees that a getX()/setX() method would give. The methods can be called any number of times, but the stored value can only be written once.

As a user of a class the "backing table" is mostly inaccessible to me
when interacting with objects. It's only exposed via var_dump() and
serialize(), the former of which is a debug functionality and the output
of latter not something I must touch.

> It would not guarantee $foo->bar === $foo->bar in all cases (though that would likely hold in the 99% case in practice), but then, $foo->getBar() === $foo->getBar() has never been guaranteed either.

Properties and methods are something different. For methods there a
reasonable expectation that *behavior* is associated with them, for
properties there is not.

Unless I missed something. Hooks are fancy methods? There is nothing intrinsic about object properties. There is nothing that says two calls to the same property’s getters are going to result in the same values. There is asynchronous php, declare ticks, etc. especially in the case of globals, there is no guarantee you even have the same object. At the end of the day, it is up to the programmer building that system / program to provide those guarantees— not the language.

I do think that, without any additional information, it would be
reasonable to assume that `$foo->bar === $foo->bar`, i.e. there would
not be side-effects until you've called a method or written to the
object in some way. So I share Tim's opinion here, but I do agree that
with hooks available this is not actually a guarantee. You could
certainly have a `$foo->random_value` property and document that it
will be different each time you call it.

That said, once the programmer has added the readonly designation to a
property, I do think that something says that two calls to the same
property will result in the same values - the readonly designation. I
disagree with the point that it's not up to the language - the
language should provide an affordance for enforcing programmer intent,
and I see no reason to even have a readonly designation if we're going
to make it easily circumventable or otherwise just a "hint".

It seems that one common counterpoint to the "let's not make it
circumventable" argument is to point out that it's already
circumventable via __get. I agree with Claude that this is not a
justification for making it *easier* to circumvent. I would also like
to note that the original RFC
(PHP: rfc:readonly_properties_v2) seems to allow
this behavior *for the purpose of lazy initialization*. With an `init`
hook, we'd have solved this problem, and could deprecate the `__get`
hack for `readonly` properties / classes.

Nicolas Grekas said "__get is certainly not legacy; removing it would
break many use cases without proper alternatives.", but note that I'm
only suggesting we could maybe deprecate __get for `readonly`
properties once we had an `init` hook - I'm not proposing deprecating
it generally. Without a counterexample, I don't think there would be
another reason for `__get` to work with `readonly` properties.

On Fri, Jul 18, 2025, at 18:48, Eric Norris wrote:

On Fri, Jul 18, 2025 at 12:01 PM Rob Landers <rob@bottled.codes> wrote:

On Fri, Jul 18, 2025, at 17:25, Tim Düsterhus wrote:

Hi

On 7/14/25 15:38, Larry Garfield wrote:

Thanks, Ilija. You expressed my concerns as well. And yes, in practice, readonly classes over-reaching is the main use case; if you’re marking individual properties readonly, then just don’t mark the one that has a hook on it (use aviz if needed) and there’s no issue.

A readonly class is not just a convenience shortcut to mark each
individual property as readonly. It has important semantics of its own,
because it forces child classes to also be readonly. And even for final
classes it communicates to the user that “I won’t be adding non-readonly
properties to the class”.

Wasn’t that the entire point of readonly classes? Because it was painful to write readonly for every property. Then if a property is readonly, the inherited property is also readonly, so, by extension: a class extending a readonly class is also readonly.

There’s no “communication” here; just logic.

Marking a class as readonly must therefore be a deliberate decision,
since it affects the public API of your class and in turn also user
expectations.

Not really. I can remove the readonly designation and manually mark every property as readonly. The behavior of the class doesn’t magically change. Or, at least, I hope it doesn’t.

Perhaps we’re thinking about this the wrong way, though? So far we’ve talked as though readonly makes the property write-once. But… what if we think of it as applying to the field, aka the backing value?

I think of readonly from the view of the public API surface of an
object. The property hooks RFC was very explicit in that property hooks
are intended to be “transparent to the user” and can be added without
breaking the public API. In other words: Whether or not a property is
implemented using a hook should be considered an implementation detail
and as a user of a class I do not care whether there is a backing value
or not.

So readonly doesn’t limit calling the get hook, or even the set hook, multiple times. Only writing to the actual value in the object table. That gives the exact same set of guarantees that a getX()/setX() method would give. The methods can be called any number of times, but the stored value can only be written once.

As a user of a class the “backing table” is mostly inaccessible to me
when interacting with objects. It’s only exposed via var_dump() and
serialize(), the former of which is a debug functionality and the output
of latter not something I must touch.

It would not guarantee $foo->bar === $foo->bar in all cases (though that would likely hold in the 99% case in practice), but then, $foo->getBar() === $foo->getBar() has never been guaranteed either.

Properties and methods are something different. For methods there a
reasonable expectation that behavior is associated with them, for
properties there is not.

Unless I missed something. Hooks are fancy methods? There is nothing intrinsic about object properties. There is nothing that says two calls to the same property’s getters are going to result in the same values. There is asynchronous php, declare ticks, etc. especially in the case of globals, there is no guarantee you even have the same object. At the end of the day, it is up to the programmer building that system / program to provide those guarantees— not the language.

I do think that, without any additional information, it would be
reasonable to assume that $foo->bar === $foo->bar, i.e. there would
not be side-effects until you’ve called a method or written to the
object in some way. So I share Tim’s opinion here, but I do agree that
with hooks available this is not actually a guarantee. You could
certainly have a $foo->random_value property and document that it
will be different each time you call it.

That said, once the programmer has added the readonly designation to a
property, I do think that something says that two calls to the same
property will result in the same values - the readonly designation. I
disagree with the point that it’s not up to the language - the
language should provide an affordance for enforcing programmer intent,
and I see no reason to even have a readonly designation if we’re going
to make it easily circumventable or otherwise just a “hint”.

It seems that one common counterpoint to the “let’s not make it
circumventable” argument is to point out that it’s already
circumventable via __get. I agree with Claude that this is not a
justification for making it easier to circumvent. I would also like
to note that the original RFC
(https://wiki.php.net/rfc/readonly_properties_v2#unset) seems to allow
this behavior for the purpose of lazy initialization. With an init
hook, we’d have solved this problem, and could deprecate the __get
hack for readonly properties / classes.

Nicolas Grekas said “__get is certainly not legacy; removing it would
break many use cases without proper alternatives.”, but note that I’m
only suggesting we could maybe deprecate __get for readonly
properties once we had an init hook - I’m not proposing deprecating
it generally. Without a counterexample, I don’t think there would be
another reason for __get to work with readonly properties.

I personally feel that making special restrictions and affordances to readonly classes is a bad language design. It is a “class” and not something special or different like an enum. This is just a class with its properties made readonly. The word says it all — read. Only.

Personally, I don’t use readonly much any more. The amount of restrictions and weird behavior just makes it impossible for any real-world use except for narrow cases the original authors of the feature dreamed up. With hooks and asymmetrical viz, it’s nearly an obsolete feature anyway.

— Rob

On 18. Jul 2025, at 23:48, Eric Norris eric.t.norris@gmail.com wrote:

On Fri, Jul 18, 2025 at 12:01 PM Rob Landers <rob@bottled.codes> wrote:

On Fri, Jul 18, 2025, at 17:25, Tim Düsterhus wrote:

Hi

On 7/14/25 15:38, Larry Garfield wrote:

Thanks, Ilija. You expressed my concerns as well. And yes, in practice, readonly classes over-reaching is the main use case; if you’re marking individual properties readonly, then just don’t mark the one that has a hook on it (use aviz if needed) and there’s no issue.

A readonly class is not just a convenience shortcut to mark each
individual property as readonly. It has important semantics of its own,
because it forces child classes to also be readonly. And even for final
classes it communicates to the user that “I won’t be adding non-readonly
properties to the class”.

Wasn’t that the entire point of readonly classes? Because it was painful to write readonly for every property. Then if a property is readonly, the inherited property is also readonly, so, by extension: a class extending a readonly class is also readonly.

There’s no “communication” here; just logic.

Marking a class as readonly must therefore be a deliberate decision,
since it affects the public API of your class and in turn also user
expectations.

Not really. I can remove the readonly designation and manually mark every property as readonly. The behavior of the class doesn’t magically change. Or, at least, I hope it doesn’t.

Perhaps we’re thinking about this the wrong way, though? So far we’ve talked as though readonly makes the property write-once. But… what if we think of it as applying to the field, aka the backing value?

I think of readonly from the view of the public API surface of an
object. The property hooks RFC was very explicit in that property hooks
are intended to be “transparent to the user” and can be added without
breaking the public API. In other words: Whether or not a property is
implemented using a hook should be considered an implementation detail
and as a user of a class I do not care whether there is a backing value
or not.

So readonly doesn’t limit calling the get hook, or even the set hook, multiple times. Only writing to the actual value in the object table. That gives the exact same set of guarantees that a getX()/setX() method would give. The methods can be called any number of times, but the stored value can only be written once.

As a user of a class the “backing table” is mostly inaccessible to me
when interacting with objects. It’s only exposed via var_dump() and
serialize(), the former of which is a debug functionality and the output
of latter not something I must touch.

It would not guarantee $foo->bar === $foo->bar in all cases (though that would likely hold in the 99% case in practice), but then, $foo->getBar() === $foo->getBar() has never been guaranteed either.

Properties and methods are something different. For methods there a
reasonable expectation that behavior is associated with them, for
properties there is not.

Unless I missed something. Hooks are fancy methods? There is nothing intrinsic about object properties. There is nothing that says two calls to the same property’s getters are going to result in the same values. There is asynchronous php, declare ticks, etc. especially in the case of globals, there is no guarantee you even have the same object. At the end of the day, it is up to the programmer building that system / program to provide those guarantees— not the language.

I do think that, without any additional information, it would be
reasonable to assume that $foo->bar === $foo->bar, i.e. there would
not be side-effects until you’ve called a method or written to the
object in some way. So I share Tim’s opinion here, but I do agree that
with hooks available this is not actually a guarantee. You could
certainly have a $foo->random_value property and document that it
will be different each time you call it.

That said, once the programmer has added the readonly designation to a
property, I do think that something says that two calls to the same
property will result in the same values - the readonly designation. I
disagree with the point that it’s not up to the language - the
language should provide an affordance for enforcing programmer intent,
and I see no reason to even have a readonly designation if we’re going
to make it easily circumventable or otherwise just a “hint”.

It seems that one common counterpoint to the “let’s not make it
circumventable” argument is to point out that it’s already
circumventable via __get. I agree with Claude that this is not a
justification for making it easier to circumvent. I would also like
to note that the original RFC
(https://wiki.php.net/rfc/readonly_properties_v2#unset) seems to allow
this behavior for the purpose of lazy initialization. With an init
hook, we’d have solved this problem, and could deprecate the __get
hack for readonly properties / classes.

Nicolas Grekas said “__get is certainly not legacy; removing it would
break many use cases without proper alternatives.”, but note that I’m
only suggesting we could maybe deprecate __get for readonly
properties once we had an init hook - I’m not proposing deprecating
it generally. Without a counterexample, I don’t think there would be
another reason for __get to work with readonly properties.

Hey all,

I allow myself to answer in one single mail, instead to all of you individually.

Honestly, I didn’t expect that this RFC will be THAT controversial. :sweat_smile:

However, I get it. There are good arguments on either side.

I did hope that the “implicit cache” is a decent middle ground, but that also didn’t work out as I thought.

As mentioned earlier, this is my very first RFC. I am at a point where I am a bit overwhelmed.

That said, Larry and I heard you and already decided to offer a split vote to enable us to at least land “set only” in 8.5.

If we didn’t misunderstood it, then y’all agreed on set (only) should be allowed?

This would IMHO already be a huge improvement compared to now; and a low hanging fruit.

Not exactly what I wanted, but it is what it is.

Long story short. We simply don’t have the time to get init sorted before feature freeze.

I offer to follow up with a “readonly init hook” RFC for 8.6 to sort the rest.

I’d appreciate if voters could settle on a yes for “set only” for 8.5.

Wdyt? Would this help to get closer to closing the discussion?

Cheers,

Nick

Le ven. 18 juil. 2025 à 18:32, Tim Düsterhus <tim@bastelstu.be> a écrit :

Hi

On 7/17/25 18:26, Larry Garfield wrote:

Given the lack of consensus both here and in off-list discussions on how to handle get hooks, we have done the following:

  • Split the RFC into two sections, one for get, one for set.
  • Expanded and refined the examples for both. The implementation is still the original, however.
  • Split the vote into two: one for allowing readonly get hooks, one for readonly set hooks.

We will start the vote sometime this weekend, most likely, unless some major feedback appears before then, and let the chips fall where they may.

After working through (most of) the discussion, I’ve now taken a look at
the updated RFC. I have the following remarks:

It is really “write-once”, which is not the same as immutable (as shown above). But there’s no reason that “write-once” need be incompatible with hooks.

This is a strawman argumentation, as I’ve outlined in my previous
emails, calling readonly “write-once” is wrong. It is a reasonable user
expectation to always get the identical value when reading a value that
may only be set once. By calling it “write-once” you are trying to shift
the focus to the write operation, which is totally irrelevant for user
expectations when interacting with readonly properties. Especially for
expectations of users that just use a class rather than writing one.

To my ears, write-once is more accurate than readonly because it sticks to the facts of how this behaves. That’s very relevant.
Using readonly to suggest immutable is where the arguments for rejecting this RFC are weak.
readonly doesn’t mean immutable, no matter how hard some want it to be…

The concern would only appear if someone is deliberately doing something non-stable

Or if someone accidentally calls a (deep) function chain that is non-pure.

Or, for a more real-world and larger example, PHP 8.4 requires this:

This is false. You are perfectly able to write this in PHP 8.4:

final readonly class Entry
{
public readonly $terms;

public function __construct(
public string $word,
public string $slug,
array $terms,
) {
$this->terms = $this->upcastTerms($terms);
}

private function upcastTerms(array $terms): array
{
$upcast = static fn (Term|array $term): Term
=> $term instanceof Term ? $term : new Term(…$term);
return array_map($upcast, $value)
}
}

In no way do you need to use a property hook.

// New code in 8.5:

$p = new PositivePoint(3, 4);
$p2 = clone($p, [‘x’ => -10]);

This is not legal code in PHP 8.5. Clone-with respects visibility and
since your asymmetric visibility RFC included the change, you are
probably aware that readonly implies protected(set).

but are now necessary to ensure that invariants are enforced.

And therefore with PHP 8.5 hooks are not necessary to enforce
invariants, except in the rare case where a public(set) readonly
property is used.

Of course it’s rare. It’s brand new…
Yet this example comes back many times.
This is how the community would like to use clone-with.
This should be acknowledged.

The fact that protected(set) is (currently) the default is not an argument to make public(set) a second class citizen.

So no guarantees are softened by this RFC.

Yes, they are. Unless __get() is implemented on a class (which is
explicitly visible as part of the public API), readonly guarantees the
immutability of identity.

Which is not really relevant when talking about immutability.
What everybody is looking for when using that word is immutable objects.

While that is an interesting idea that has been floated a few times, it has enough complexities and edge cases of its own to address that we feel it is out of scope.

While it certainly is your right as the RFC authors to consider certain
things out of scope for an RFC, I strongly oppose the notion of shipping
something that is strictly inferior and comes with obvious semantic
issues due to perceived complexity of another solution and then
following up with the proper solution that has already been identified.
As I’ve outlined in my previous emails, I found defining semantics for
an ‘init’ hook straight-forward when looking at how PHP works as of today.

However, this RFC is in no way incompatible with adding an init hook in the future should it be proposed.

This is true, but as I’ve mentioned before, an ‘init’ hook would enable
the same use cases without bringing along issues. So it really should be
“one of them, but not both” (with “one of them” being the init hook).


After reading through the discussion, it seems the only argument against
the ‘init’ hook is perceived complexity. It is not at all clear to me
why this means that we must now rush something with clear issues into
PHP 8.5.

I’d understand the arguments you’re pushing for if readonly were appropriate to build immutable objects. Yet that’s not the case, so such reasoning is built on sand I’m sorry…

To me the RFC enables useful capabilities that authors are going to need. Or find workarounds for. Which means more ugliness to come…

Nicolas

Hi

[dropping most of the folks from the Cc list to reduce noise a little]

On 7/18/25 19:08, Nick wrote:

As mentioned earlier, this is my very first RFC. I am at a point where I am a bit overwhelmed.

With an RFC touching core language semantics, you've certainly opted to touch a hot topic. Even for me, as an experienced contributor to Internals and the PHP language that was quite a few emails to work through, so I totally get being overwhelmed.

I think a big contributor to this is that you've choosen a less-than-ideal time to propose the RFC. As you are well-aware, by now feature freeze is in less than 4 weeks. Many of the core contributors, including myself, are busy with wrapping up the implementation of their own RFCs or helping with the review of others. Keeping track of multiple last-minute RFCs at the same time and carefully thinking about the implications and then, should the RFC pass, also about the implementation is really demanding.

I'm in the lucky position that I can spend part of my company time contributing to PHP, proposing RFCs and also providing feedback on other RFCs. But I can't make this my full-time job, so I've specifically set aside several hours my free time today to catch up with the thread to be able to provide my feedback on the RFC. The announcement of the upcoming vote has caused me to send out several emails all over the thread that were less refined than would be usual for me, because I felt the need to get out *something* before it's too late.

That said, Larry and I heard you and already decided to offer a split vote to enable us to at least land “set only” in 8.5.
If we didn’t misunderstood it, then y’all agreed on `set` (only) should be allowed?

I am unable to come up with arguments against supporting a set hook for readonly properties. While I'm not sure I would be in favor (I would need to think about this more), I would not be against. So I might abstain or I might vote in favor after I had time to fully think about it.

> Not exactly what I wanted, but it is what it is.

I totally understand it's discouraging when needing to wait for another year before being able to make use of one's own contributions. But at the same time any change to the language has a big impact on the ecosystem and needs to work not for just one year, but for 10 or more years. Some things can probably never be removed from the language. So to me it's important to err on the side of caution. "No is temporary, yes is forever".

I offer to follow up with a “readonly `init` hook” RFC for 8.6 to sort the rest.

I'm certainly happy to help out with figuring out all the important details and possible edge cases for an "init hook" RFC for the PHP 8.6 cycle. Note how I specifically left out the "readonly" there, since I don't think there is a need to restrict "init" to just readonly properties.

I’d appreciate if voters could settle on a yes for “set only” for 8.5.

Wdyt? Would this help to get closer to closing the discussion?

From my side, removing the get hook part from the RFC would definitely settle the discussion.

Best regards
Tim Düsterhus

Hi

On 7/18/25 17:49, Rob Landers wrote:

Wasn’t that the entire point of readonly classes? Because it was painful to write readonly for every property.

It was certainly *one point*, but not the *entire* point. Quoting from the RFC (PHP: rfc:readonly_classes):

"it's still not easy to declare (quasi-)immutable classes"

and then further:

"it will prevent the creation of dynamic properties"

and

"a readonly class can only extend a readonly parent"

So the point of the RFC is not "adding readonly for every property is verbose", but "I want to be able to define immutable classes", which, as I outlined before, is something different.

The behavior of the class doesn’t magically change. Or, at least, I hope it doesn’t.

The behavior of the class changes, since dynamic properties will now be legal. It also breaks any child classes, since child classes of non-readonly classes may not be readonly (not even if all properties are already readonly). The behavior doesn't change magically, though, it changes due to the intentional removal of the `readonly` keyword on the class.

Unless I missed something. Hooks are fancy methods? There is nothing intrinsic about object properties. There is nothing that says two calls to the same property’s getters are going to result in the same values. There is asynchronous php, declare ticks, etc. especially in the case of globals, there is no guarantee you even have the same object. At the end of the day, it is up to the programmer building that system / program to provide those guarantees— not the language.

I agree with both Eric's response to this paragraph.

Best regards
Tim Düsterhus