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.
https://wiki.php.net/rfc/readonly_hooks
--
Larry Garfield
larry@garfieldtech.com
Hey Larry,
Couple points from a first read and from trying to run the examples.
a) From the "ProductFromDB" i get:
Fatal error: Uncaught TypeError: LazyProduct::$category::get(): Return
value must be of type Category, none returned in ...
I assume you're missing a return statement here? Or is there something I'm
missing?
b) Minor wording gripe in the proposal section
On the other hand, there is no shortage of dumb things that people can do
with PHP already.
While I don't disagree that PHP gives people a lot of freedom in how they
want to write their code, I find it a bit crude for an RFC to phrase it
like that. And the __get comparison is strong enough to stand on its own.
c)
That is, we feel, an entirely reasonable use of hooks, and would allow
for lazy-load behavior per-property on readonly classes.
I might be misunderstanding the sentence here, but on-demand/Lazy
initialization of properties on readonly classes is already possible in
classic getter/setter classes.
Do you mean you want to have parity to this behavior when using hooks? I'm
all for it, I just feel the sentence says this enables something that
wasn't possible before but if you mean this in scope property access it
makes sense to me.
d) PositivePoint
Example doesn't compile against 8.4, master, or
against NickSdot:readonly-hooks
Can you make this a script that runs and shows expected output so that
readers don't have to assume this is supposed to do or run it?
e) Backward Incompatible Changes
Section is not filled in yet
f) Date: 2024-07-10
Is this correct? I know you created the page back then, but was there a
discussion already that I wasn't able to find?
Kind Regards,
Volker
On Sun, Jun 8, 2025 at 6:18 AM Larry Garfield larry@garfieldtech.com
wrote:
As Nick has graciously provided an implementation, we would like to open
discussion on this very small RFC to allowreadonly
on backed properties
even if they have a hook defined.https://wiki.php.net/rfc/readonly_hooks
--
Larry Garfield
larry@garfieldtech.com
--
Volker Dusch
Head of Engineering
Tideways GmbH
Königswinterer Str. 116
53227 Bonn
https://tideways.io/imprint
Sitz der Gesellschaft: Bonn
Geschäftsführer: Benjamin Außenhofer (geb. Eberlei)
Registergericht: Amtsgericht Bonn, HRB 22127
Hey Volker,
a) From the "ProductFromDB" i get:
Fatal error: Uncaught TypeError: LazyProduct::$category::get(): Return value must be of type Category, none returned in ...
I assume you're missing a return statement here? Or is there something I'm missing?
d) PositivePoint
Example doesn't compile against 8.4, master, or against NickSdot:readonly-hooks
Can you make this a script that runs and shows expected output so that readers don't have to assume this is supposed to do or run it?
Unfortunate typos in the RFC text. I can’t yet update the RFC text myself, but we will make sure they will be fixed shortly!
Meanwhile, you can find the full running code for both examples here:
NickSdot:readonly-hooks/Zend/tests/property_hooks/readonly_rfc_example_lazy_product.phpt
NickSdot:readonly-hooks/Zend/tests/property_hooks/readonly_rfc_example_validation.phpt
Thanks for pointing these out!
—
I leave addressing the other points to Larry.
Cheers,
Nick
Hey Larry,
Couple points from a first read and from trying to run the examples.
a) From the "ProductFromDB" i get:
Fatal error: Uncaught TypeError: LazyProduct::$category::get(): Return value must be of type Category, none returned in ...
I assume you're missing a return statement here? Or is there something
I'm missing?b) Minor wording gripe in the proposal section
On the other hand, there is no shortage of dumb things that people can do with PHP already.
While I don't disagree that PHP gives people a lot of freedom in how
they want to write their code, I find it a bit crude for an RFC to
phrase it like that. And the __get comparison is strong enough to stand
on its own.
Various typos have been fixed, and the code should be valid now. The wording has also been adapted (though I am pretty sure "silly" has appeared in RFCs before).
I also fleshed out the __get mention with an example that shows what you can already do today, and in fact could since 8.1 when readonly was introduced. The hard guarantee of idempotency has never actually been there. (This also speaks to Claude's concern.)
c)
That is, we feel, an entirely reasonable use of hooks, and would allow for lazy-load behavior per-property on readonly classes.
I might be misunderstanding the sentence here, but on-demand/Lazy
initialization of properties on readonly classes is already possible in
classic getter/setter classes.
If a class uses private properties and getX/setX methods, sure, those methods can be overridden to do whatever you want. The whole point of hooks, though, is to NOT need those methods. We want to enable someone to define a read-model easily, like the Product in the example. That's all they should need to specify. So, yes, technically it's "in a class that is designed with modern features" rather than having a long list of getX/setX methods just to give lazy-loading generated code a place to hook in.
f) Date: 2024-07-10
Is this correct? I know you created the page back then, but was there a
discussion already that I wasn't able to find?
Yes, this RFC was originally spun off from the hook-improvements RFC, as it needed more discussion while the other half of that RFC was uncontroversial. I've added a link to the prior thread for reference.
--Larry Garfield
Hi
Am 2025-06-09 17:11, schrieb Larry Garfield:
I also fleshed out the __get mention with an example that shows what
you can already do today, and in fact could since 8.1 when readonly was
introduced. The hard guarantee of idempotency has never actually been
there. (This also speaks to Claude's concern.)
I don't think this really resolves Claude's concern. While it is
certainly true that the guarantees do not currently hold, I don't
believe this is strong enough of a reason not to provide for stronger
guarantees in a newly introduced feature. The point of property hooks
is for me that “dynamic properties” are easier to reason about compared
to __get()
. As a user when accessing a proper readonly
property, I
do not want to check if there is a property hook that might result in
non-readonly behavior. As an engine developer I want to be able to
optimize based on the readonly
-ness of a property. Without such
guarantees, the “readonly” keyword does not provide value to me.
I also believe the LazyProduct example to be broken, since lazy-loading
individual properties might result in an object that is internally
consistent if the database changes in-between.
Best regards
Tim Düsterhus
Hi
Am 2025-06-09 17:11, schrieb Larry Garfield:
I also fleshed out the __get mention with an example that shows what
you can already do today, and in fact could since 8.1 when readonly was
introduced. The hard guarantee of idempotency has never actually been
there. (This also speaks to Claude's concern.)I don't think this really resolves Claude's concern. While it is
certainly true that the guarantees do not currently hold, I don't
believe this is strong enough of a reason not to provide for stronger
guarantees in a newly introduced feature. The point of property hooks
is for me that “dynamic properties” are easier to reason about compared
to__get()
. As a user when accessing a properreadonly
property, I
do not want to check if there is a property hook that might result in
non-readonly behavior. As an engine developer I want to be able to
optimize based on thereadonly
-ness of a property. Without such
guarantees, the “readonly” keyword does not provide value to me.
The only way to make the readonliness fully guaranteed would be to force a readonly property to be cached; (IE, the hook is only called at all if the property is uninitialized.) But there's no obvious way to make that clear in the code that it's what's happening.
I also believe the LazyProduct example to be broken, since lazy-loading
individual properties might result in an object that is internally
consistent if the database changes in-between.
That's true with any lazy-loading scenario. The use of hooks doesn't change that at all.
--Larry Garfield
Hi Larry et al.
Le mar. 8 juil. 2025 à 17:12, Larry Garfield larry@garfieldtech.com a
écrit :
Hi
Am 2025-06-09 17:11, schrieb Larry Garfield:
I also fleshed out the __get mention with an example that shows what
you can already do today, and in fact could since 8.1 when readonly was
introduced. The hard guarantee of idempotency has never actually been
there. (This also speaks to Claude's concern.)I don't think this really resolves Claude's concern. While it is
certainly true that the guarantees do not currently hold, I don't
believe this is strong enough of a reason not to provide for stronger
guarantees in a newly introduced feature. The point of property hooks
is for me that “dynamic properties” are easier to reason about compared
to__get()
. As a user when accessing a properreadonly
property, I
do not want to check if there is a property hook that might result in
non-readonly behavior. As an engine developer I want to be able to
optimize based on thereadonly
-ness of a property. Without such
guarantees, the “readonly” keyword does not provide value to me.The only way to make the readonliness fully guaranteed would be to force a
readonly property to be cached; (IE, the hook is only called at all if the
property is uninitialized.) But there's no obvious way to make that clear
in the code that it's what's happening.I also believe the LazyProduct example to be broken, since lazy-loading
individual properties might result in an object that is internally
consistent if the database changes in-between.That's true with any lazy-loading scenario. The use of hooks doesn't
change that at all.
This RFC makes sense to me.
I read Claude's concern, and I agree with Larry's response: the engine
already allows readonly to be bypassed using __get. The added hook doesn't
make anything more lenient.
I also read Tim's argument that new features could be stricter. If one
wants to be stricter and forbid extra behaviors that could be added by
either the proposed hooks or __get, then the answer is : make the class
final. This is the only real way to enforce readonly-ness in PHP.
If a class is final and uses readonly with either hooks or __get, then
that's the original author's choice. There's no need for extra
engine-assisted strictness in this case. You cannot write such code in a
non-readonly way by mistake, so it has to be by intent.
Nicolas
PS: as I keep repeating, readonly doesn't immutable at all. I know this is
written as such in the original RFC, but the concrete definition and
implementation of readonly isn't: you can set mutable objects to readonly
properties, and that means even readonly classes/properties are mutable, in
the generic case.
Le 8 juil. 2025 à 17:32, Nicolas Grekas nicolas.grekas+php@gmail.com a écrit :
I read Claude's concern, and I agree with Larry's response: the engine already allows readonly to be bypassed using __get. The added hook doesn't make anything more lenient.
It is true that readonly could be bypassed by __get(); but this is a legacy behaviour, and you have to take an explicit step to make it possible. For those unaware of the awful hack, here is a minimal test case:
where the unset(...)
is mandatory to make it “work”.
Are we obligated to sanction shortcomings of legacy concepts in newly introduced concepts that are supposed to replace them? Or can we do something better? I’ve outlined in a previous email what I think is a better design for such situation (namely an init
hook).
Also, the fact that __get() is not yet deprecated means that we can still use the aforementioned hack until/unless we’ve implemented a proper solution. In the worst case, you can still use a non-readonly hooked property and document the intended invariants in phpdoc.
If a class is final and uses readonly with either hooks or __get, then that's the original author's choice. There's no need for extra engine-assisted strictness in this case. You cannot write such code in a non-readonly way by mistake, so it has to be by intent.
Enforcing as strictly as possible its intended invariants is a good design for a robust language. Yes, it implies that users cannot (or can hardly) escape annoying constraints. For example, you can’t extend a final class, even if you think that you have good reason for it, like constructing a mock object.
—Claude
Thanks for your detailed thoughts, Claude. I'd like to offer my perspective
on some of the points you raised.
Le mer. 9 juil. 2025 à 12:53, Claude Pache claude.pache@gmail.com a
écrit :
Le 8 juil. 2025 à 17:32, Nicolas Grekas nicolas.grekas+php@gmail.com a
écrit :I read Claude's concern, and I agree with Larry's response: the engine
already allows readonly to be bypassed using __get. The added hook doesn't
make anything more lenient.It is true that readonly could be bypassed by __get(); but this is a
legacy behaviour, and you have to take an explicit step to make it
possible. For those unaware of the awful hack, here is a minimal test case:where the
unset(...)
is mandatory to make it “work”.Are we obligated to sanction shortcomings of legacy concepts in newly
introduced concepts that are supposed to replace them? Or can we do
something better? I’ve outlined in a previous email what I think is a
better design for such situation (namely aninit
hook).Also, the fact that __get() is not yet deprecated means that we can still
use the aforementioned hack until/unless we’ve implemented a proper
solution. In the worst case, you can still use a non-readonly hooked
property and document the intended invariants in phpdoc.
__get is certainly not legacy; removing it would break many use cases
without proper alternatives.
The behavior after unset() has been promoted to a language feature when
readonly properties were introduced because it helps solve real world use
cases.
I've been asked recently by Gina if those use cases were covered by eg
native lazy proxies. The answer is no, because native lazy proxies cover
only part of the lazy-proxying domain: what remains is proxying by
interface and proxying internal classes, and those require a way to proxy
all property accesses, which is why magic methods are required.
With the argument that __get can be used to implement the
non-readonly-ness, we could also say that hooks are not needed, because
they can be implemented using __get. Yet, language aesthetics are
important, and we welcomed hooks for this reason. Being able to easily
lazy-init thanks to hooks on readonly would be a welcome improvement to me.
That being said, about your init proposal, I think that could work. I'd
just do it a bit differently: instead of introducing a new "init" hook, I'd
prefer having "set" mean "init" for readonly properties. But I know nothing
about the engine on the topic so I can't comment on the feasibility aspect.
I'll leave this to others.
Just a word about using hooks vs __get for lazy-init: the really hard part
when using __get is emulating the public/protected/private visibility
rules. Hooks make this a non-issue. Yet hooks - unfortunately - can't be
used as a generic lazy-init implementation because of their behavior
related to references. That's another topic, but still related, to
reinforce that __get is certainly not legacy.
If a class is final and uses readonly with either hooks or __get, then
that's the original author's choice. There's no need for extra
engine-assisted strictness in this case. You cannot write such code in a
non-readonly way by mistake, so it has to be by intent.Enforcing as strictly as possible its intended invariants is a good design
for a robust language. Yes, it implies that users cannot (or can hardly)
escape annoying constraints. For example, you can’t extend a final class,
even if you think that you have good reason for it, like constructing a
mock object.
That's not strictness when the root concept is already filled with
conceptual holes... I'm surprised nobody ever proposed the concept of an
immutable keyword, that'd be like readonly but that'd accept only
also-immutable values. Until this happens, using readonly for that is
a fallacy I'm sorry... To me that invalidates all related arguments.
Nicolas
Thanks for your detailed thoughts, Claude. I'd like to offer my perspective on some of the points you raised.
Le mer. 9 juil. 2025 à 12:53, Claude Pache claude.pache@gmail.com a écrit :
Le 8 juil. 2025 à 17:32, Nicolas Grekas <nicolas.grekas+php@gmail.com mailto:nicolas.grekas%2Bphp@gmail.com> a écrit :
I read Claude's concern, and I agree with Larry's response: the engine already allows readonly to be bypassed using __get. The added hook doesn't make anything more lenient.
It is true that readonly could be bypassed by __get(); but this is a legacy behaviour, and you have to take an explicit step to make it possible. For those unaware of the awful hack, here is a minimal test case:
where the
unset(...)
is mandatory to make it “work”.Are we obligated to sanction shortcomings of legacy concepts in newly introduced concepts that are supposed to replace them? Or can we do something better? I’ve outlined in a previous email what I think is a better design for such situation (namely an
init
hook).Also, the fact that __get() is not yet deprecated means that we can still use the aforementioned hack until/unless we’ve implemented a proper solution. In the worst case, you can still use a non-readonly hooked property and document the intended invariants in phpdoc.
__get is certainly not legacy; removing it would break many use cases without proper alternatives.
The behavior after unset() has been promoted to a language feature when readonly properties were introduced because it helps solve real world use cases.
I've been asked recently by Gina if those use cases were covered by eg native lazy proxies. The answer is no, because native lazy proxies cover only part of the lazy-proxying domain: what remains is proxying by interface and proxying internal classes, and those require a way to proxy all property accesses, which is why magic methods are required.With the argument that __get can be used to implement the non-readonly-ness, we could also say that hooks are not needed, because they can be implemented using __get. Yet, language aesthetics are important, and we welcomed hooks for this reason. Being able to easily lazy-init thanks to hooks on readonly would be a welcome improvement to me.
That being said, about your init proposal, I think that could work. I'd just do it a bit differently: instead of introducing a new "init" hook, I'd prefer having "set" mean "init" for readonly properties. But I know nothing about the engine on the topic so I can't comment on the feasibility aspect. I'll leave this to others.
Just a word about using hooks vs __get for lazy-init: the really hard part when using __get is emulating the public/protected/private visibility rules. Hooks make this a non-issue. Yet hooks - unfortunately - can't be used as a generic lazy-init implementation because of their behavior related to references. That's another topic, but still related, to reinforce that __get is certainly not legacy.
If a class is final and uses readonly with either hooks or __get, then that's the original author's choice. There's no need for extra engine-assisted strictness in this case. You cannot write such code in a non-readonly way by mistake, so it has to be by intent.
Enforcing as strictly as possible its intended invariants is a good design for a robust language. Yes, it implies that users cannot (or can hardly) escape annoying constraints. For example, you can’t extend a final class, even if you think that you have good reason for it, like constructing a mock object.
That's not strictness when the root concept is already filled with conceptual holes... I'm surprised nobody ever proposed the concept of an immutable keyword, that'd be like readonly but that'd accept only also-immutable values. Until this happens, using readonly for that is a fallacy I'm sorry... To me that invalidates all related arguments.
Nicolas
https://wiki.php.net/rfc/records
I’ll probably return back to it after 8.5 is released. Knowing what I know today, there are a lot of things id remove.
— Rob
Hey Claude,
Le 8 juil. 2025 à 17:32, Nicolas Grekas nicolas.grekas+php@gmail.com a écrit :
I read Claude's concern, and I agree with Larry's response: the engine already allows readonly to be bypassed using __get. The added hook doesn't make anything more lenient.
It is true that readonly could be bypassed by __get(); but this is a legacy behaviour, and you have to take an explicit step to make it possible. For those unaware of the awful hack, here is a minimal test case:
where the
unset(...)
is mandatory to make it “work”.Are we obligated to sanction shortcomings of legacy concepts in newly introduced concepts that are supposed to replace them? Or can we do something better? I’ve outlined in a previous email what I think is a better design for such situation (namely an
init
hook).Also, the fact that __get() is not yet deprecated means that we can still use the aforementioned hack until/unless we’ve implemented a proper solution. In the worst case, you can still use a non-readonly hooked property and document the intended invariants in phpdoc.
If a class is final and uses readonly with either hooks or __get, then that's the original author's choice. There's no need for extra engine-assisted strictness in this case. You cannot write such code in a non-readonly way by mistake, so it has to be by intent.
Enforcing as strictly as possible its intended invariants is a good design for a robust language. Yes, it implies that users cannot (or can hardly) escape annoying constraints. For example, you can’t extend a final class, even if you think that you have good reason for it, like constructing a mock object.
—Claude
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?
That said I did address your concern here (actual RFC PR branch against alternative; PoC):
https://github.com/NickSdot/php-php-src/compare/allow-readonly-hooks...NickSdot:php-php-src:readonly-hooks-strict
Larry and I agree that we don’t want this complexity in the current RFC.
Perhaps this is something for a separate init
hook RFC?
Cheers,
Nick
(Sorry for the duplicate. I forgot to CC the list)
Le 9 juil. 2025 à 15:17, Nick php@nicksdot.dev a écrit :
Hey Claude,
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.
That said I did address your concern here (actual RFC PR branch against alternative; PoC):
https://github.com/NickSdot/php-php-src/compare/allow-readonly-hooks...NickSdot:php-php-src:readonly-hooks-strictLarry and I agree that we don’t want this complexity in the current RFC.
Perhaps this is something for a separateinit
hook RFC?
I think indeed that it is not worth making the current proposal more complex, but rather considering whether implementing an init hook instead is a reasonable alternative.
Also there is another issue with the use of get hook for lazy initialisation (although not specific to readonly): The ??=
pattern breaks if the property is nullable and you initialise it to null
. It is in fact cumbersome to distinguish between an uninitialised property and a property initialised with null.
—Claude
Le 8 juil. 2025 à 17:32, Nicolas Grekas nicolas.grekas+php@gmail.com a écrit :
I read Claude's concern, and I agree with Larry's response: the engine already allows readonly to be bypassed using __get. The added hook doesn't make anything more lenient.
It is true that readonly could be bypassed by __get(); but this is a
legacy behaviour, and you have to take an explicit step to make it
possible. For those unaware of the awful hack, here is a minimal test
case:where the
unset(...)
is mandatory to make it “work”.Are we obligated to sanction shortcomings of legacy concepts in newly
introduced concepts that are supposed to replace them? Or can we do
something better? I’ve outlined in a previous email what I think is a
better design for such situation (namely aninit
hook).Also, the fact that __get() is not yet deprecated means that we can
still use the aforementioned hack until/unless we’ve implemented a
proper solution. In the worst case, you can still use a non-readonly
hooked property and document the intended invariants in phpdoc.If a class is final and uses readonly with either hooks or __get, then that's the original author's choice. There's no need for extra engine-assisted strictness in this case. You cannot write such code in a non-readonly way by mistake, so it has to be by intent.
Enforcing as strictly as possible its intended invariants is a good
design for a robust language. Yes, it implies that users cannot (or can
hardly) escape annoying constraints. For example, you can’t extend a
final class, even if you think that you have good reason for it, like
constructing a mock object.—Claude
Here's the core problem right now:
-
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 bewriteonce
, because that's all it is. (Once again, this is likely the most poorly designed feature we've added in many years.) -
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, becausereadonly
-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).
In practice, there's 2 scenarios that I see as useful (or problematic in 8.4, that we want to support):
-
set hooks for validation, which don't impact writeonce-ness. I think everyone seems on board with that.
-
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." :-)
Ilija and I had discussed making readonly
imply cached/lazy/init in the original hooks RFC, but decided against it. Mainly, it becomes very confusing if a property is going to store a value, as there's three different scenarios to consider: There's a short-set hook, the property is mentioned in its own hooks, and then look for readonly. (Would that mean readonly only works on virtual properties?) It makes a feature that's already, in all honesty, at the edge of reasonable complexity more complex.
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???"
I'd be open to a follow up RFC for an init hook, though I likely wouldn't write it myself. But that's a different topic than what we're addressing here.
--Larry Garfield
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???"
I'd be open to a follow up RFC for an init hook, though I likely wouldn't write it myself. But that's a different topic than what we're addressing here.
--Larry Garfield
I'm not entirely sure I follow - it sounds like your email states that
readonly
should be interpreted as writeonce
, which makes sense,
but then why would an init
hook not be the appropriate answer here?
The two scenarios you listed (set
hooks for validation and lazy
computed properties) seem like they could be solved by allowing set
hooks (everyone seems +1 to that), an init
hook, and disallowing
get
hooks. It would sidestep the controversial nature of a get
hook for the property.
It feels to me like an init hook would be the more conservative
approach, and would (I imagine) still allow for potential readonly
engine optimizations like Tim pointed out. Once we allow get
hooks,
there's no going back. If we still needed to add get
hooks in the
future, it's not off the table.
I don't know that I feel strongly here, but there does seem something
intuitively off with allowing a get hook for a readonly (writeonce)
property.
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???"
I'd be open to a follow up RFC for an init hook, though I likely wouldn't write it myself. But that's a different topic than what we're addressing here.
--Larry Garfield
I'm not entirely sure I follow - it sounds like your email states that
readonly
should be interpreted aswriteonce
, which makes sense,
but then why would aninit
hook not be the appropriate answer here?The two scenarios you listed (
set
hooks for validation and lazy
computed properties) seem like they could be solved by allowingset
hooks (everyone seems +1 to that), aninit
hook, and disallowing
get
hooks. It would sidestep the controversial nature of aget
hook for the property.It feels to me like an init hook would be the more conservative
approach, and would (I imagine) still allow for potentialreadonly
engine optimizations like Tim pointed out. Once we allowget
hooks,
there's no going back. If we still needed to addget
hooks in the
future, it's not off the table.I don't know that I feel strongly here, but there does seem something
intuitively off with allowing a get hook for a readonly (writeonce)
property.
Can an init hook reference itself, the way get and set can?
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?
Should combining init and set be forbidden as confusing?
Can you have both an init hook and a get hook? What happens then?
Repeat all of the above on readonly properties.
I don't know the answer to any of those. We could probably collectively figure out some answers to that in time, but that's a much larger lift than either Nick or I have any interest in engaging in at this point, especially when there is a reasonable solution right in front of us that is trivial to implement.
--Larry Garfield
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???"
I'd be open to a follow up RFC for an init hook, though I likely wouldn't write it myself. But that's a different topic than what we're addressing here.
--Larry Garfield
I'm not entirely sure I follow - it sounds like your email states that
readonly
should be interpreted aswriteonce
, which makes sense,
but then why would aninit
hook not be the appropriate answer here?The two scenarios you listed (
set
hooks for validation and lazy
computed properties) seem like they could be solved by allowingset
hooks (everyone seems +1 to that), aninit
hook, and disallowing
get
hooks. It would sidestep the controversial nature of aget
hook for the property.It feels to me like an init hook would be the more conservative
approach, and would (I imagine) still allow for potentialreadonly
engine optimizations like Tim pointed out. Once we allowget
hooks,
there's no going back. If we still needed to addget
hooks in the
future, it's not off the table.I don't know that I feel strongly here, but there does seem something
intuitively off with allowing a get hook for a readonly (writeonce)
property.Can an init hook reference itself, the way get and set can?
I apologize for being ignorant here, but I'm not sure what you mean by
"referencing itself". Do you mean the backing value? No, because the
backing value is unset - this is the initialization (and in the case
of readonly, this is the "writeonce").
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?
Should combining init and set be forbidden as confusing?
I don't have strong feelings here, but I think this is solvable in a
way that is consistent and unsurprising. If you set the value, the set
hook triggers, if the set hook reads the value, it triggers the init
hook, if it's readonly, this might trigger an error since the set hook
would try to set it a second time.
Can you have both an init hook and a get hook? What happens then?
I think, at least for readonly, you couldn't have an init hook and a
get hook, since the main objection here is to having get hooks on
readonly properties at all. On normal properties, I think that'd be
okay?
I don't know the answer to any of those. We could probably collectively figure out some answers to that in time, but that's a much larger lift than either Nick or I have any interest in engaging in at this point, especially when there is a reasonable solution right in front of us that is trivial to implement.
I guess I don't agree that this is a reasonable solution at the
moment. I don't know that I think it's unreasonable, either, but
something about it feels wrong to me. I agree with Tim that I think of
readonly properties as guaranteeing the immutability of identity.
Regarding the caching option suggested elsewhere, the semantics
mentioned seem confusing to me. The body is called on subsequent gets,
but only the value from the first get is returned? I would expect that
would be very confusing to developers. If the body wasn't executed on
subsequent invocations that might make more sense to me, but then the
get hook essentially is an init hook, and why not call it as such.
Also, just to throw the idea out there - maybe start with init hooks
only for readonly properties? Is there something to this, especially
if you consider "readonly" actually "writeonce"? Maybe this plus
disallowing set hooks?
Hey Eric,
Regarding the caching option suggested elsewhere, the semantics
mentioned seem confusing to me. The body is called on subsequent gets,
but only the value from the first get is returned? I would expect that
would be very confusing to developers. If the body wasn't executed on
subsequent invocations that might make more sense to me, but then the
get hook essentially is an init hook, and why not call it as such.
I just before pushed a third implementation idea which addresses this.
It also no longer requires a separate cache. It uses the store itself.
A link to the branch is in the description of the original PR.
Also, just to throw the idea out there - maybe start with init hooks
only for readonly properties? Is there something to this, especially
if you consider "readonly" actually "writeonce"? Maybe this plus
disallowing set hooks?
Personally, I don’t see added value in adding yet another hook init
.
I also don’t see how this would solve the get
hook issue?
As a user I want to be able to have a get
hook in readonly classes. For instance, for formatting.
In my last answer to Tim I showed an example of what I would expect from readonly
properties.
Cheers,
Nick
Hi
Am 2025-07-08 17:32, schrieb Nicolas Grekas:
I also read Tim's argument that new features could be stricter. If one
wants to be stricter and forbid extra behaviors that could be added by
either the proposed hooks or __get, then the answer is : make the class
final. This is the only real way to enforce readonly-ness in PHP.
Making the class final still would not allow to optimize based on the
fact that the identity of a value stored in a readonly property will not
change after successfully reading from the property once. Whether or not
a property hooked must be considered an implementation detail, since a
main point of the property hooks RFC was that hooks can be added and
removed without breaking compatibility for the user of the API.
engine-assisted strictness in this case. You cannot write such code in
a
non-readonly way by mistake, so it has to be by intent.
That is saying "it's impossible to introduce bugs".
PS: as I keep repeating, readonly doesn't immutable at all. I know this
is
written as such in the original RFC, but the concrete definition and
implementation of readonly isn't: you can set mutable objects to
readonly
properties, and that means even readonly classes/properties are
mutable, in
the generic case.
readonly
guarantees the immutability of identity. While you can
certainly mutate mutable objects, the identity of the stored object
doesn't change.
Best regards
Tim Düsterhus
Hi
Am 2025-07-08 17:32, schrieb Nicolas Grekas:
I also read Tim's argument that new features could be stricter. If one
wants to be stricter and forbid extra behaviors that could be added by
either the proposed hooks or __get, then the answer is : make the class
final. This is the only real way to enforce readonly-ness in PHP.Making the class final still would not allow to optimize based on the
fact that the identity of a value stored in a readonly property will not
change after successfully reading from the property once. Whether or not
a property hooked must be considered an implementation detail, since a
main point of the property hooks RFC was that hooks can be added and
removed without breaking compatibility for the user of the API.engine-assisted strictness in this case. You cannot write such code in
a
non-readonly way by mistake, so it has to be by intent.That is saying "it's impossible to introduce bugs".
PS: as I keep repeating, readonly doesn't immutable at all. I know this
is
written as such in the original RFC, but the concrete definition and
implementation of readonly isn't: you can set mutable objects to
readonly
properties, and that means even readonly classes/properties are
mutable, in
the generic case.
readonly
guarantees the immutability of identity. While you can
certainly mutate mutable objects, the identity of the stored object
doesn't change.Best regards
Tim Düsterhus
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.)
--Larry Garfield
Hi
Am 2025-07-08 17:32, schrieb Nicolas Grekas:
I also read Tim's argument that new features could be stricter. If one
wants to be stricter and forbid extra behaviors that could be added by
either the proposed hooks or __get, then the answer is : make the class
final. This is the only real way to enforce readonly-ness in PHP.Making the class final still would not allow to optimize based on the
fact that the identity of a value stored in a readonly property will not
change after successfully reading from the property once. Whether or not
a property hooked must be considered an implementation detail, since a
main point of the property hooks RFC was that hooks can be added and
removed without breaking compatibility for the user of the API.engine-assisted strictness in this case. You cannot write such code in
a
non-readonly way by mistake, so it has to be by intent.That is saying "it's impossible to introduce bugs".
PS: as I keep repeating, readonly doesn't immutable at all. I know this
is
written as such in the original RFC, but the concrete definition and
implementation of readonly isn't: you can set mutable objects to
readonly
properties, and that means even readonly classes/properties are
mutable, in
the generic case.
readonly
guarantees the immutability of identity. While you can
certainly mutate mutable objects, the identity of the stored object
doesn't change.Best regards
Tim DüsterhusNick 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.)
--Larry Garfield
I think that only covers one use-case for getters on readonly classes. Take this example for discussion:
readonly class User {
public int $elapsedTimeSinceCreation { get => time()
- $this->createdAt; }
private int $cachedResult;
public int $totalBalance { get => $this->cachedResult ??= 5+10; }
public int $accessLevel { get => getCurrentAccessLevel(); }
public function __construct(public int $createdAt) {}
}
$user = new User(time() - 5);
var_dump($user->elapsedTimeSinceCreation); // 5
var_dump($user->totalBalance); // 15
var_dump($user->accessLevel); // 42
In this example, we have three of the most common ones:
- Computed Properties (elapsedTimeSinceCreation): these are properties of the object that are relevant to the object in question, but are not static. In this case, you are not writing to the object. It is still "readonly".
- Memoization (expensiveCalculation): only calculate the property once and only once. This is a performance optimization. It is still "readonly".
- External State (accessLevel): properties of the object that rely on some external state, which due to architecture or other convienence may not make sense as part of object construction. It is still "readonly".
You can mix-and-match these to provide your own level of immutability, but memoization is certainly not the only one.
You could make the argument that these should be functions, but I'd posit that these are properties of the user object. In other words, a function to get these values would probably be named getElapsedTimeSinceCreation()
, getTotalBalance
, or getAccessLevel
-- we'd be writing getters anyway.
— Rob
Hey Rob,
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.)
--Larry Garfield
I think that only covers one use-case for getters on readonly classes. Take this example for discussion:
readonly class User {
public int $elapsedTimeSinceCreation { get =>time()
- $this->createdAt; }
private int $cachedResult;
public int $totalBalance { get => $this->cachedResult ??= 5+10; }
public int $accessLevel { get => getCurrentAccessLevel(); }
public function __construct(public int $createdAt) {}
}$user = new User(time() - 5);
var_dump($user->elapsedTimeSinceCreation); // 5
var_dump($user->totalBalance); // 15
var_dump($user->accessLevel); // 42In this example, we have three of the most common ones:
Computed Properties (elapsedTimeSinceCreation): these are properties of the object that are relevant to the object in question, but are not static. In this case, you are not writing to the object. It is still "readonly".
Memoization (expensiveCalculation): only calculate the property once and only once. This is a performance optimization. It is still "readonly".
External State (accessLevel): properties of the object that rely on some external state, which due to architecture or other convienence may not make sense as part of object construction. It is still "readonly".
You can mix-and-match these to provide your own level of immutability, but memoization is certainly not the only one.You could make the argument that these should be functions, but I'd posit that these are properties of the user object. In other words, a function to get these values would probably be named
getElapsedTimeSinceCreation()
,getTotalBalance
, orgetAccessLevel
-- we'd be writing getters anyway.— Rob
Please remember that the RFC will allow readonly
on backed properties only, not on virtual hooked properties.
Nothing from your example would work, and it would result in:
Fatal error: Hooked virtual properties cannot be declared readonly
My proposed alternative implementation with caching addresses the concern Claude and Tim had and will make this hold:
class Unusual
{
public function __construct(
public readonly int $value {
get => $this->value * random_int(1, 100);
}
) {}
}
$unusual = new Unusual(1);
var_dump($unusual->value === $unusual->value); // true
– Nick
Hey Rob,
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.)
--Larry Garfield
I think that only covers one use-case for getters on readonly classes. Take this example for discussion:
readonly class User {
public int $elapsedTimeSinceCreation { get =>time()
- $this->createdAt; }
private int $cachedResult;
public int $totalBalance { get => $this->cachedResult ??= 5+10; }
public int $accessLevel { get => getCurrentAccessLevel(); }
public function __construct(public int $createdAt) {}
}$user = new User(time() - 5);
var_dump($user->elapsedTimeSinceCreation); // 5
var_dump($user->totalBalance); // 15
var_dump($user->accessLevel); // 42In this example, we have three of the most common ones:
- Computed Properties (elapsedTimeSinceCreation): these are properties of the object that are relevant to the object in question, but are not static. In this case, you are not writing to the object. It is still "readonly".
- Memoization (expensiveCalculation): only calculate the property once and only once. This is a performance optimization. It is still "readonly".
- External State (accessLevel): properties of the object that rely on some external state, which due to architecture or other convienence may not make sense as part of object construction. It is still "readonly".
You can mix-and-match these to provide your own level of immutability, but memoization is certainly not the only one.You could make the argument that these should be functions, but I'd posit that these are properties of the user object. In other words, a function to get these values would probably be named
getElapsedTimeSinceCreation()
,getTotalBalance
, orgetAccessLevel
-- we'd be writing getters anyway.— Rob
Please remember that the RFC will allow
readonly
on backed properties only, not on virtual hooked properties.
Nothing from your example would work, and it would result in:
Fatal error: Hooked virtual properties cannot be declared readonly
My proposed alternative implementation with caching addresses the concern Claude and Tim had and will make this hold:class Unusual { public function __construct( public readonly int $value { get => $this->value * random_int(1, 100); } ) {} } $unusual = new Unusual(1); var_dump($unusual->value === $unusual->value); // true ``` – Nick
Hey Nick,
I was merely illustrating the different types, you can simply replace them with non-virtual examples. To your idea of forced memoization, I'm not sure that's a good idea. In cases where its a single execution context per request, it may be fine, but in cases of worker mode on frankenphp, where readonly objects may live for quite awhile, it may not be. Generally, memoization is an optimization, not a property of a value.
In your example above, this breaks the principle of least astonishment, and potentially violates referential transparency due to the calculation being non-pure. Referential transparency basically says we can can replace the use of the variable with it's value (in this case, $ususual->value with $unusual->backing_value * random_int(1, 100)). If it is memoized, we cannot. There isn't a way to express that it is one value the first time (random) and a different value the next time (the previous computation). This is a bit dangerous because the programmer has no way to reliably reason about the code as if the expression can be substituted freely.
— Rob
Please remember that the RFC will allow
readonly
on backed properties only, not on virtual hooked properties.
Nothing from your example would work, and it would result in:
Fatal error: Hooked virtual properties cannot be declared readonly
My proposed alternative implementation with caching addresses the concern Claude and Tim had and will make this hold:class Unusual { public function __construct( public readonly int $value { get => $this->value * random_int(1, 100); } ) {} } $unusual = new Unusual(1); var_dump($unusual->value === $unusual->value); // true
– Nick
Hey Nick,
I was merely illustrating the different types, you can simply replace them with non-virtual examples. To your idea of forced memoization, I'm not sure that's a good idea. In cases where its a single execution context per request, it may be fine, but in cases of worker mode on frankenphp, where readonly objects may live for quite awhile, it may not be. Generally, memoization is an optimization, not a property of a value.
In your example above, this breaks the principle of least astonishment, and potentially violates referential transparency due to the calculation being non-pure. Referential transparency basically says we can can replace the use of the variable with it's value (in this case, $ususual->value with $unusual->backing_value * random_int(1, 100)). If it is memoized, we cannot. There isn't a way to express that it is one value the first time (random) and a different value the next time (the previous computation). This is a bit dangerous because the programmer has no way to reliably reason about the code as if the expression can be substituted freely.
— Rob
Hey Rob,
-
Well, we cannot have it both in the same time. Either we want a strict guarantee that the value doesn’t change after the first
get
hook call on areadonly
property, or we want the opposite. So the proposed solution now is to be strict by default. If you want it non-cached you can use nonreadonly
. -
The same point could be made outside of "readonly hooks", for everything with any kind of randomness or external state (database, filesystem, …) source. That’s the trade-off.
I find it very acceptable as a solution for the concern brought up by Claude and Tim.
Cheers,
Nick
Aside: I just learned about your “records” RFC. Good stuff!
Hi
Am 2025-07-08 17:32, schrieb Nicolas Grekas:
I also read Tim's argument that new features could be stricter. If one
wants to be stricter and forbid extra behaviors that could be added by
either the proposed hooks or __get, then the answer is : make the class
final. This is the only real way to enforce readonly-ness in PHP.Making the class final still would not allow to optimize based on the
fact that the identity of a value stored in a readonly property will not
change after successfully reading from the property once. Whether or not
a property hooked must be considered an implementation detail, since a
main point of the property hooks RFC was that hooks can be added and
removed without breaking compatibility for the user of the API.engine-assisted strictness in this case. You cannot write such code in
a
non-readonly way by mistake, so it has to be by intent.That is saying "it's impossible to introduce bugs".
PS: as I keep repeating, readonly doesn't immutable at all. I know this
is
written as such in the original RFC, but the concrete definition and
implementation of readonly isn't: you can set mutable objects to
readonly
properties, and that means even readonly classes/properties are
mutable, in
the generic case.
readonly
guarantees the immutability of identity. While you can
certainly mutate mutable objects, the identity of the stored object
doesn't change.Best regards
Tim DüsterhusNick 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.)
--Larry Garfield
I think that only covers one use-case for getters on readonly classes. Take this example for discussion:
readonly class User {
public int $elapsedTimeSinceCreation { get =>time()
- $this->createdAt; }
private int $cachedResult;
public int $totalBalance { get => $this->cachedResult ??= 5+10; }
public int $accessLevel { get => getCurrentAccessLevel(); }
public function __construct(public int $createdAt) {}
}$user = new User(time() - 5);
var_dump($user->elapsedTimeSinceCreation); // 5
var_dump($user->totalBalance); // 15
var_dump($user->accessLevel); // 42In this example, we have three of the most common ones:
Computed Properties (elapsedTimeSinceCreation): these are properties of the object that are relevant to the object in question, but are not static. In this case, you are not writing to the object. It is still "readonly".
Memoization (expensiveCalculation): only calculate the property once and only once. This is a performance optimization. It is still "readonly".
External State (accessLevel): properties of the object that rely on some external state, which due to architecture or other convienence may not make sense as part of object construction. It is still "readonly".
You can mix-and-match these to provide your own level of immutability, but memoization is certainly not the only one.You could make the argument that these should be functions, but I'd posit that these are properties of the user object. In other words, a function to get these values would probably be named
getElapsedTimeSinceCreation()
,getTotalBalance
, orgetAccessLevel
-- we'd be writing getters anyway.— Rob
Hey Rob,
We ended up where we are now because more people than not voiced that they would expect a readonly
property value to never change after get
was first called.
As you can see in my earlier mails I also was of a different opinion. I asked "what if a user wants exactly that”?
You brought good examples for when “that" could be the case.
It is correct, with the current alternative implementations your examples would be cached.
A later call to the property would not use the updated time or a potentially updated external state.
After thinking a lot about it over the last days I think that makes sense.
To stick to your usage of time()
, I think the following is a good example:
readonly class JobHelper
{
public function __construct(
public readonly string $uniqueRunnerKey {
get => 'runner/' . date("Ymd_H-i-s", `time()`) . '_' . (string) random_int(1, 100) . '/'. $this->uniqueRunnerKey;
}
) {}
}
$helper = new JobHelper('report.txt’);
$key1 = $helper->uniqueRunnerKey;
sleep(2);
$key2 = $helper->uniqueRunnerKey;
var_dump($key1 === $key2); // true
It has two dynamic path elements, to achieve some kind of randomness. As a user you still can expect $key1 === $key2 to hold when using readonly
.
Claude's argument is strong, because we also cannot write twice to a readonly
property.
So it’s fair to say reading should also be predictable, and return the exact same value on consecutive calls.
If users don’t want that, they can opt-out by not using readonly
. The guarantee only holds in combination with readonly
.
Alternatively, as you proposed, using methods (which I think would really be a better fit; alternatively virtual properties which also will not support readonly
.
With what we have now, both “camps" will be able to achieve what they want transparently.
And I believe that’s a good middle ground we should go forward with.
Cheers,
Nick
Hi
Am 2025-07-08 17:32, schrieb Nicolas Grekas:
I also read Tim's argument that new features could be stricter. If one
wants to be stricter and forbid extra behaviors that could be added by
either the proposed hooks or __get, then the answer is : make the class
final. This is the only real way to enforce readonly-ness in PHP.Making the class final still would not allow to optimize based on the
fact that the identity of a value stored in a readonly property will not
change after successfully reading from the property once. Whether or not
a property hooked must be considered an implementation detail, since a
main point of the property hooks RFC was that hooks can be added and
removed without breaking compatibility for the user of the API.engine-assisted strictness in this case. You cannot write such code in
a
non-readonly way by mistake, so it has to be by intent.That is saying "it's impossible to introduce bugs".
PS: as I keep repeating, readonly doesn't immutable at all. I know this
is
written as such in the original RFC, but the concrete definition and
implementation of readonly isn't: you can set mutable objects to
readonly
properties, and that means even readonly classes/properties are
mutable, in
the generic case.
readonly
guarantees the immutability of identity. While you can
certainly mutate mutable objects, the identity of the stored object
doesn't change.Best regards
Tim DüsterhusNick 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.)
--Larry Garfield
I think that only covers one use-case for getters on readonly classes. Take this example for discussion:
readonly class User {
public int $elapsedTimeSinceCreation { get =>time()
- $this->createdAt; }
private int $cachedResult;
public int $totalBalance { get => $this->cachedResult ??= 5+10; }
public int $accessLevel { get => getCurrentAccessLevel(); }
public function __construct(public int $createdAt) {}
}$user = new User(time() - 5);
var_dump($user->elapsedTimeSinceCreation); // 5
var_dump($user->totalBalance); // 15
var_dump($user->accessLevel); // 42In this example, we have three of the most common ones:
- Computed Properties (elapsedTimeSinceCreation): these are properties of the object that are relevant to the object in question, but are not static. In this case, you are not writing to the object. It is still "readonly".
- Memoization (expensiveCalculation): only calculate the property once and only once. This is a performance optimization. It is still "readonly".
- External State (accessLevel): properties of the object that rely on some external state, which due to architecture or other convienence may not make sense as part of object construction. It is still "readonly".
You can mix-and-match these to provide your own level of immutability, but memoization is certainly not the only one.You could make the argument that these should be functions, but I'd posit that these are properties of the user object. In other words, a function to get these values would probably be named
getElapsedTimeSinceCreation()
,getTotalBalance
, orgetAccessLevel
-- we'd be writing getters anyway.— Rob
Hey Rob,We ended up where we are now because more people than not voiced that they would expect a
readonly
property value to never change afterget
was first called.
As you can see in my earlier mails I also was of a different opinion. I asked "what if a user wants exactly that”?
You brought good examples for when “that" could be the case.It is correct, with the current alternative implementations your examples would be cached.
A later call to the property would not use the updated time or a potentially updated external state.After thinking a lot about it over the last days I think that makes sense.
To stick to your usage of
time()
, I think the following is a good example:readonly class JobHelper { public function __construct( public readonly string $uniqueRunnerKey { get => 'runner/' . date("Ymd_H-i-s", `time()`) . '_' . (string) random_int(1, 100) . '/'. $this->uniqueRunnerKey; } ) {} } $helper = new JobHelper('report.txt’); $key1 = $helper->uniqueRunnerKey; sleep(2); $key2 = $helper->uniqueRunnerKey; var_dump($key1 === $key2); // true
It has two dynamic path elements, to achieve some kind of randomness. As a user you still can expect $key1 === $key2 to hold when using
readonly
.Claude's argument is strong, because we also cannot write twice to a
readonly
property.
So it’s fair to say reading should also be predictable, and return the exact same value on consecutive calls.If users don’t want that, they can opt-out by not using
readonly
. The guarantee only holds in combination withreadonly
.
Alternatively, as you proposed, using methods (which I think would really be a better fit; alternatively virtual properties which also will not supportreadonly
.With what we have now, both “camps" will be able to achieve what they want transparently.
And I believe that’s a good middle ground we should go forward with.Cheers,
Nick
Hey Nick,
After sleeping on it, I think I agree with this assessment. For backed properties, especially, it makes sense that the value feels "immutable". If and when we get to virtual properties, maybe not. But that's a bridge to cross later.
— Rob
Hi
Am 2025-07-08 17:10, schrieb Larry Garfield:
The only way to make the readonliness fully guaranteed would be to
force a readonly property to be cached
Or by not allowing a get
hook on readonly properties, of course.
Best regards
Tim Düsterhus
Hey Tim,
Hi
Am 2025-07-08 17:10, schrieb Larry Garfield:
The only way to make the readonliness fully guaranteed would be to force a readonly property to be cached
Or by not allowing a
get
hook on readonly properties, of course.Best regards
Tim Düsterhus
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.
If get
would not be allowed, we couldn’t do such an obvious thing. For what reason?
Instead we would need to delegate formatting to the set
hook which is messy.
readonly class Foo
{
public function __construct(
public Output $style,
public string $some {
set => '' !== $value ? (Output::One === $this->style ? ucfirst($value) : strtoupper($value)) : throw new \Exception();
}
) {}
}
Now that I have proposed alternative implementations with caching, I don’t see why get
should not be allowed.
Cheers,
Nick
Aside: I added two links to alternative implementations to the PR description.
Hi Tim,
Le mar. 1 juil. 2025 à 16:29, Tim Düsterhus tim@bastelstu.be a écrit :
Hi
Am 2025-06-09 17:11, schrieb Larry Garfield:
I also fleshed out the __get mention with an example that shows what
you can already do today, and in fact could since 8.1 when readonly was
introduced. The hard guarantee of idempotency has never actually been
there. (This also speaks to Claude's concern.)I don't think this really resolves Claude's concern. While it is
certainly true that the guarantees do not currently hold, I don't
believe this is strong enough of a reason not to provide for stronger
guarantees in a newly introduced feature. The point of property hooks
is for me that “dynamic properties” are easier to reason about compared
to__get()
. As a user when accessing a properreadonly
property, I
do not want to check if there is a property hook that might result in
non-readonly behavior. As an engine developer I want to be able to
optimize based on thereadonly
-ness of a property. Without such
guarantees, the “readonly” keyword does not provide value to me.I also believe the LazyProduct example to be broken, since lazy-loading
individual properties might result in an object that is internally
consistent if the database changes in-between.
Here are two situations that are perfectly valid use cases for the example:
- event-sourced / versioned entities in the DB, where the state of an
object cannot change in the backend - lazy-parsed network payloads, where one parses only part of some JSONs on
demand (mongodb and symfony/json-streamer do such things already)
Nicolas
Le 8 juin 2025 à 06:16, Larry Garfield larry@garfieldtech.com a écrit :
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.https://wiki.php.net/rfc/readonly_hooks
--
Larry Garfield
larry@garfieldtech.com
Hi Larry, Nick,
Last summer, the question of allowing hooks on readonly has been raised as part of the RFC «Property hooks improvements», and at that time I have raised an objection on allowing the get hook on readonly properties and I have suggested for a better design for the main issue it was supposed to solve, see https://externals.io/message/124149#124187 and the following messages. (The RFC itself was trimmed down to the non-controversial part.) I’ll repeat here both my objection and my proposal for better design, but more strongly, with the hope that the message will be received.
The purpose of readonly properties is (citing the original RFC, https://wiki.php.net/rfc/readonly_properties_v2#rationale) to provide strong immutable guarantee, i.e.:
class Test {
public readonly string $prop;
public function method(Closure $fn) {
$prop = $this->prop;
$fn(); // Any code may run here.
$prop2 = $this->prop;
assert($prop === $prop2); // Always holds.
}
}
By allowing a get hook on readonly property, you are effectively nullifying this invariant. Invariants must be enforced be the engines (whenever possible; there is an inevitable loophole until the property is initialised), and not left to the discretion of the user. If a get hook on readonly property is allowed, a random user will use its creativity in order to circumvent the intended invariant (recall: immutability). I say “creativity”, not “dumbness”, because you cannot mechanically tell the two apart:
class doc {
public readonly int page {
get => $this->page + $this->offset;
}
private int $offset = 0;
public function __construct(int $page) {
$this->page = $page;
}
public function foo() {
// $this->offset may be adjusted here
}
}
I know that some people won’t see a problem with that code (see the cited thread above), and this is a strong reason not to allow that: you cannot trust the user to enforce invariants that they don’t understand or are not interested in.
(The objection above is for the get
hook; there is no such issue with the
set` hook.)
Now, here is the suggestion for a better alternative design, that (1) don’t allow to break the invariant of immutability, (2) solve the issue of lazy initialisation (which is, I guess, the main purpose of the get
hook on readonly), and (3) also works with nullable properties:
Add an additional hook to backed properties, named init
. When attempting to read the value of the backing store, if it is uninitialised, then the init hook is triggered, which is supposed to initialise it.
—Claude
As Nick has graciously provided an implementation, we would like to
open discussion on this very small RFC to allowreadonly
on backed
properties even if they have a hook defined.
After some back and forth on the PR to settle on error messages, this RFC seems ready. Baring any other feedback we will open the vote on it sometime on Wednesday.
--Larry Garfield
Hey all,
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.https://wiki.php.net/rfc/readonly_hooks
--
Larry Garfield
larry@garfieldtech.com
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 believe that these are fair solutions to address the concerns that came up in the discussion, and I hope people will agree.
Cheers,
Nick
Le 11 juil. 2025 à 06:30, Nick php@nicksdot.dev a écrit :
Hey all,
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.https://wiki.php.net/rfc/readonly_hooks
--
Larry Garfield
larry@garfieldtech.comTo 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/18757I believe that these are fair solutions to address the concerns that came up in the discussion, and I hope people will agree.
Cheers,
Nick
Hi Nick,
I think that the second alternative described as:
“Cache computed get hook to it's backing store; never run the hook again”
is near the most reasonable from my point of view (basing my judgment on the description, as I have not looked the actual implementation), although there are still some concerns.
Advantages of that approach:
-
relatively to the first alternative solution: there is indeed no point to have a cache separate from the backing-store, as the backing-store is supposed to play that role in most cases;
-
relatively to the manual “??=” pattern, it works correctly with nullable properties.
Here are my remaining concerns:
A. It may be confusing to have a getter that is not always called.
B. The idea of returning the value directly from the backing-store if initialised is useful also for non-readonly properties. (That can be emulated with the “??=” pattern, but only if the property is not nullable.)
Both concerns may be resolved with the following amendment:
- Introduce a
cached
modifier, that enables the caching semantics (i.e., not executing the getter if the backing-store is initialised).
The example from the RFC would be written as:
readonly class LazyProduct extends Product {
private DbConnection $dbApi;
private string $categoryId;
public Category $category {
cached get => $this->dbApi->loadCategory($this->categoryId);
}
}
The cached
modifier may be applied to any get hook, but it is mandatory if the property is readonly.
(In practice, the “cached get hook” corresponds to my originally proposed “init hook”, but with the advantage of not having a separate hook.)
—Claude
Le 11 juil. 2025 à 06:30, Nick php@nicksdot.dev a écrit :
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/18757I believe that these are fair solutions to address the concerns that came up in the discussion, and I hope people will agree.
Cheers,
NickHi Nick,
I think that the second alternative described as:
“Cache computed get hook to it's backing store; never run the hook again”
is near the most reasonable from my point of view (basing my judgment on the description, as I have not looked the actual implementation), although there are still some concerns.
Advantages of that approach:
relatively to the first alternative solution: there is indeed no point to have a cache separate from the backing-store, as the backing-store is supposed to play that role in most cases;
relatively to the manual “??=” pattern, it works correctly with nullable properties.
Here are my remaining concerns:
A. It may be confusing to have a getter that is not always called.
B. The idea of returning the value directly from the backing-store if initialised is useful also for non-readonly properties. (That can be emulated with the “??=” pattern, but only if the property is not nullable.)
Both concerns may be resolved with the following amendment:
- Introduce a
cached
modifier, that enables the caching semantics (i.e., not executing the getter if the backing-store is initialised).The example from the RFC would be written as:
readonly class LazyProduct extends Product { private DbConnection $dbApi; private string $categoryId; public Category $category { cached get => $this->dbApi->loadCategory($this->categoryId); } }
The
cached
modifier may be applied to any get hook, but it is mandatory if the property is readonly.(In practice, the “cached get hook” corresponds to my originally proposed “init hook”, but with the advantage of not having a separate hook.)
—Claude
Hey Claude,
I agree, the second alternative is more neat.
A.
Three people seem to think that always running the hook when returning a cached value would cause confusion.
You are now arguing for the exact opposite. I don’t have a very strong opinion here.
However, I also came to the conclusion that caching and not running the hook is the more obvious and less confusing approach.
Also, naturally it’s more performant to not run the hook again.
Running the hook again, but then not updating the value feels off.
And updating the value would mean no caching, which brings us to the very beginning and your very concern.
So, yeah… that’s that. :)
B.
I am afraid here I do have a strong opinion. Please remember my very first mail before discussion [1].
While for Larry the main reason for readonly
hooks is lazy-initialisation, for me it is to write less code, to have less noisy classes.
I don’t want to be forced to add readonly to each property.
Now you are proposing a mandatory cached
modifier. Which means checks notes, I would save 2 characters on each property.
You will understand that this is not in my interest, and not what I am proposing here.
But aside from that I do not like to write more code. I honestly don’t see the benefit of having this modifier in the first place.
How “alternative implementation 2” works now is IMO just good. And it makes sense because it is limited to the readonly
context.
There is no technical need for the modifier. As we see, because we have a working solution at hand.
For me: It is clear, it’s solving the main issue you rightfully brought up, and it is less code to write.
I can imagine that cached
could be helpful on non readonly
properties in some cases.
However, I feel that brings us almost in similar territory than immutable classes.
It is a whole new topic which I believe really shouldn’t be part of this RFC that explicitly .
Can we please agree on that it is future scope whether or not non readonly
hooks should get such a modifier?
I’d appreciate if we could settle with “alternative implementation 2” as proposed.
Cheers,
Nick
Le 11 juil. 2025 à 11:38, Nick php@nicksdot.dev a écrit :
I am afraid here I do have a strong opinion. Please remember my very first mail before discussion [1].
While for Larry the main reason forreadonly
hooks is lazy-initialisation, for me it is to write less code, to have less noisy classes.
I don’t want to be forced to add readonly to each property.
Now you are proposing a mandatorycached
modifier. Which means checks notes, I would save 2 characters on each property.
You will understand that this is not in my interest, and not what I am proposing here.But aside from that I do not like to write more code. I honestly don’t see the benefit of having this modifier in the first place.
How “alternative implementation 2” works now is IMO just good. And it makes sense because it is limited to thereadonly
context.
There is no technical need for the modifier. As we see, because we have a working solution at hand.
The reason I prefer an explicit cached
modifier (as opposed to have it implied by the fact that the property is readonly), is because it changes the semantics in the following ways:
-
the get hook is bypassed in more situations than non-cached get hooks;
-
(depending on the exact implementation details), the result of the get hook is used to populate the backing-store. (Alternatively, we could leave the get hook populate it, and just check that the value it returns matches the value on the backing-store. The latter check is mandatory, but I think we could automatically initialise the backing-store if needed.)
By contrast, modifiers like private
or readonly
do not change the semantics, but only add restrictions on when the relevant operation is allowed.
But this is just my opinion for making code more obvious (as opposed to save few keystrokes). If other people agree that cached
may be implied by readonly
, I won’t fight against that.
Can we please agree on that it is future scope whether or not non
readonly
hooks should get such a modifier?
Personally, I don’t have objection to relegate non-readonly cached get hooks to future scope.
—Claude
Le 11 juil. 2025 à 06:30, Nick php@nicksdot.dev a écrit :
To not get this buried in individual answers to others:
I came up with two alternative implementations which cache the
computedget
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/18757I believe that these are fair solutions to address the concerns that
came up in the discussion, and I hope people will agree.Cheers,
NickHi Nick,
I think that the second alternative described as:
“Cache computed get hook to it's backing store; never run the hook again”
is near the most reasonable from my point of view (basing my judgment
on the description, as I have not looked the actual implementation),
although there are still some concerns.Advantages of that approach:
relatively to the first alternative solution: there is indeed no
point to have a cache separate from the backing-store, as the
backing-store is supposed to play that role in most cases;relatively to the manual “??=” pattern, it works correctly with
nullable properties.Here are my remaining concerns:
A. It may be confusing to have a getter that is not always called.
B. The idea of returning the value directly from the backing-store if
initialised is useful also for non-readonly properties. (That can be
emulated with the “??=” pattern, but only if the property is not
nullable.)Both concerns may be resolved with the following amendment:
- Introduce a
cached
modifier, that enables the caching semantics
(i.e., not executing the getter if the backing-store is initialised).The example from the RFC would be written as:
readonly class LazyProduct extends Product { private DbConnection $dbApi; private string $categoryId; public Category $category { cached get => $this->dbApi->loadCategory($this->categoryId); } }
The
cached
modifier may be applied to any get hook, but it is
mandatory if the property is readonly.(In practice, the “cached get hook” corresponds to my originally
proposed “init hook”, but with the advantage of not having a separate
hook.)—Claude
Hey Claude,
I agree, the second alternative is more neat.
A.
Three people seem to think that always running the hook when returning
a cached value would cause confusion.
You are now arguing for the exact opposite. I don’t have a very strong
opinion here.
However, I also came to the conclusion that caching and not running
the hook is the more obvious and less confusing approach.
Also, naturally it’s more performant to not run the hook again.Running the hook again, but then not updating the value feels off.
And updating the value would mean no caching, which brings us to the
very beginning and your very concern.
So, yeah… that’s that. :)B.
I am afraid here I do have a strong opinion. Please remember my very
first mail before discussion [1].
While for Larry the main reason forreadonly
hooks is
lazy-initialisation, for me it is to write less code, to have less
noisy classes.
I don’t want to be forced to add readonly to each property.
Now you are proposing a mandatorycached
modifier. Which means
checks notes, I would save 2 characters on each property.
You will understand that this is not in my interest, and not what I am
proposing here.But aside from that I do not like to write more code. I honestly don’t
see the benefit of having this modifier in the first place.
How “alternative implementation 2” works now is IMO just good. And it
makes sense because it is limited to thereadonly
context.
There is no technical need for the modifier. As we see, because we
have a working solution at hand.For me: It is clear, it’s solving the main issue you rightfully
brought up, and it is less code to write.I can imagine that
cached
could be helpful on nonreadonly
properties in some cases.
However, I feel that brings us almost in similar territory than
immutable classes.
It is a whole new topic which I believe really shouldn’t be part of
this RFC that explicitly .
Can we please agree on that it is future scope whether or not non
readonly
hooks should get such a modifier?I’d appreciate if we could settle with “alternative implementation 2”
as proposed.
Hi Nick, Claude,
I think it's important to explicitly mark it as "this value will be
stored in memory", I mean just silently caching the get hook could
quickly lead to unexpected behavior. Like one would expect the value to
be changed and another one wonders why a big chunk of memory will not be
freed get => $this->readBigFile();
.
On the one hand I like the cached modifier but personally I would prefer
a separate init hook because it seems to be more clear that this is a
backed property that will be initialized once.
The cached modifier I would expect to be an attribute applicable to any
function which uses another cache store similar to how it's possible in
python to memorize function calls which would be a very different feature.
Just my two cents from someone without voting rights
Cheers,
Nick
Hi Nick, Claude,
Hey Marc,
I think it's important to explicitly mark it as "this value will be stored in memory", I mean just silently caching the get hook could quickly lead to unexpected behavior. Like one would expect the value to be changed
The most here made the argument that "a changing value from a readonly get hook" would be the unexpected behaviour.
That’s why we ended up with the current “alternative implementation 2”. Please see my last answer to Rob for a fair example [1] .
The current preferred alternative implementation covers both situations...
If a property is readonly
:
- you can set once
- on read you always get the same (once computed) value back
If a property is NOT readonly
:
- you can set often
- on read you always get the fresh (often computed) value back
I argue that this is a very easy mental model.
I hope that voters agree on “cached may be implied by readonly”, as Claude called it.
and another one wonders why a big chunk of memory will not be freed
get => $this->readBigFile();
.
Where do you see non-freed memory in one but not the other?
- in both scenarios, readonly or not, the
readBigFile()
will end up in memory. - on each consecutive property call the usage in both scenarios is the same.
- when assigning a call the same property to multiple tmp vars the cached, once-computed version uses less memory than the non-cached version
Do I miss something? Did I misunderstand something?
Additionally, the cached version has the benefit that the expensive computation only happens once.
On the one hand I like the cached modifier but personally I would prefer a separate init hook because it seems to be more clear that this is a backed property that will be initialized once.
To have an init
hook doesn’t solve the get hook issue.
The cached modifier I would expect to be an attribute applicable to any function which uses another cache store similar to how it's possible in python to memorize function calls which would be a very different feature.
As earlier answered to Claude [2], I seek to write less code. To introduce a cached
modifier voids this for no strong reason (please see “mental model” above).
--
Cheers,
Nick
[1] https://news-web.php.net/php.internals/128010
[2] https://news-web.php.net/php.internals/128007
Hi Nick, Claude,
Hey Marc,
I think it's important to explicitly mark it as "this value will be
stored in memory", I mean just silently caching the get hook could
quickly lead to unexpected behavior. Like one would expect the value
to be changedThe most here made the argument that "a changing value from a readonly
get hook" would be the unexpected behaviour.
That’s why we ended up with the current “alternative implementation
2”. Please see my last answer to Rob for a fair example [1] .The current preferred alternative implementation covers both situations...
If a property is
readonly
:
- you can set once
- on read you always get the same (once computed) value back
This is exactly the behavior I mean which is somehow unexpected if not
marked explicitly as the result could be cached and the property value
will never change.
Not being able to write to something doesn't generally mean reading the
value will never change. get => random_int(0, 100);
I don't want to say that the path we want to go with readonly being able
to assume the value will never change is wrong but I think it's not
clear for the user and can be missed quickly - even with a test as you
have to read the property multiple time to notice the difference.
If a property is NOT
readonly
:
- you can set often
- on read you always get the fresh (often computed) value back
I argue that this is a very easy mental model.
I hope that voters agree on “|cached| may be implied by |readonly|”,
as Claude called it.
All what I'm saying is that this behavior should be explicit and not
applied implicitly on a readonly get hook.
and another one wonders why a big chunk of memory will not be freed
get => $this->readBigFile();
.Where do you see non-freed memory in one but not the other?
- in both scenarios, readonly or not, the
readBigFile()
will end up
in memory.- on each consecutive property call the usage in both scenarios is the
same.- when assigning a call the same property to multiple tmp vars the
cached, once-computed version uses less memory than the non-cached versionDo I miss something? Did I misunderstand something?
yes, in both cases the data will end up in memory until all references
are garbaged. The difference is that the object of that property now has
a reference as well and needs to be destroyed as well be able to free
the memory.
Additionally, the cached version has the benefit that the expensive
computation only happens once.On the one hand I like the cached modifier but personally I would
prefer a separate init hook because it seems to be more clear that
this is a backed property that will be initialized once.To have an
init
hook doesn’t solve the get hook issue.
As far as I understood the init hook it would still disallow
readonly+get but allow readonly+init and init would be called once the
first time the property gets read and the result would end up in the
backing store.
As a result you get the same behavior as cached get
with the only
difference that you write init =>
random_int();
instead of cached get =>
random_int();
Additionally it can be used to initialize a property for non readonly
properties as well.
class Test {
public int $seek {
init => random_int(0, 100); // called once on read if not
initialized
get => $this->seek + 100;
set => $this->seek = $value + 100;
}
}
var_dump((new Test())->seek); // number between 100-200 OR 200-300
depending of the set hook be called once with the result of init as well.
Or did I misunderstand it?
The cached modifier I would expect to be an attribute applicable to
any function which uses another cache store similar to how it's
possible in python to memorize function calls which would be a very
different feature.As earlier answered to Claude [2], I seek to write less code. To
introduce acached
modifier voids this for no strong reason (please
see “mental model” above).
See above - and init
is just once more character.
--
Cheers,
Nick[1] https://news-web.php.net/php.internals/128010
[2] https://news-web.php.net/php.internals/128007
Hi Nick, Claude,
Hey Marc,
I think it's important to explicitly mark it as "this value will be stored in memory", I mean just silently caching the get hook could quickly lead to unexpected behavior. Like one would expect the value to be changed
The most here made the argument that "a changing value from a readonly get hook" would be the unexpected behaviour.
That’s why we ended up with the current “alternative implementation 2”. Please see my last answer to Rob for a fair example [1] .The current preferred alternative implementation covers both situations...
If a property is
readonly
:
- you can set once
- on read you always get the same (once computed) value back
This is exactly the behavior I mean which is somehow unexpected if not marked explicitly as the result could be cached and the property value will never change.
Not being able to write to something doesn't generally mean reading the value will never change.
get => random_int(0, 100);
I don't want to say that the path we want to go with readonly being able to assume the value will never change is wrong but I think it's not clear for the user and can be missed quickly - even with a test as you have to read the property multiple time to notice the difference.
If a property is NOT
readonly
:
- you can set often
- on read you always get the fresh (often computed) value back
I argue that this is a very easy mental model.
I hope that voters agree on “cached may be implied by readonly”, as Claude called it.All what I'm saying is that this behavior should be explicit and not applied implicitly on a readonly get hook.
I agree with Marc here, not surprisingly.
To have an
init
hook doesn’t solve the get hook issue.As far as I understood the init hook it would still disallow readonly+get but allow readonly+init and init would be called once the first time the property gets read and the result would end up in the backing store.
As a result you get the same behavior as
cached get
with the only difference that you writeinit =>
random_int();
instead ofcached get =>
random_int();
Agreed. Nick, I am not sure how the init hook doesn't "solve the get
hook issue". As I understand it, the get hook issue is that get hooks
are not allowed on readonly properties. The init hook would be
identical to a "cached get" hook on a readonly property, so why
doesn't it solve the issue?
Or did I misunderstand it?
I share the same understanding as you, Marc.
The cached modifier I would expect to be an attribute applicable to any function which uses another cache store similar to how it's possible in python to memorize function calls which would be a very different feature.
As earlier answered to Claude [2], I seek to write less code. To introduce a
cached
modifier voids this for no strong reason (please see “mental model” above).See above - and
init
is just once more character.
I was going to respond to this point earlier, but Marc beat me to it.
An "init" hook is one more character than a get hook, is explicit over
a get hook that works differently only for readonly properties, and
is far fewer characters than the explicit "cached" modifier get hook
option.
On top of that, as Claude mentioned an init hook provides the ability
to differentiate between a null property and an uninitialized property
- an init hook would only be called for uninitialized properties, so
no need for $this->foo ??= "bar".
Hi Nick, Claude,
Hey Marc,
I think it's important to explicitly mark it as "this value will be stored in memory", I mean just silently caching the get hook could quickly lead to unexpected behavior. Like one would expect the value to be changed
The most here made the argument that "a changing value from a readonly get hook" would be the unexpected behaviour.
That’s why we ended up with the current “alternative implementation 2”. Please see my last answer to Rob for a fair example [1] .The current preferred alternative implementation covers both situations...
If a property is
readonly
:
- you can set once
- on read you always get the same (once computed) value back
This is exactly the behavior I mean which is somehow unexpected if not marked explicitly as the result could be cached and the property value will never change.
Not being able to write to something doesn't generally mean reading the value will never change.
get => random_int(0, 100);
I don't want to say that the path we want to go with readonly being able to assume the value will never change is wrong but I think it's not clear for the user and can be missed quickly - even with a test as you have to read the property multiple time to notice the difference.
If a property is NOT
readonly
:
- you can set often
- on read you always get the fresh (often computed) value back
I argue that this is a very easy mental model.
I hope that voters agree on “cached may be implied by readonly”, as Claude called it.All what I'm saying is that this behavior should be explicit and not applied implicitly on a readonly get hook.
I agree with Marc here, not surprisingly.
To have an
init
hook doesn’t solve the get hook issue.As far as I understood the init hook it would still disallow readonly+get but allow readonly+init and init would be called once the first time the property gets read and the result would end up in the backing store.
As a result you get the same behavior as
cached get
with the only difference that you writeinit =>
random_int();
instead ofcached get =>
random_int();
Agreed. Nick, I am not sure how the init hook doesn't "solve the get
hook issue". As I understand it, the get hook issue is that get hooks
are not allowed on readonly properties. The init hook would be
identical to a "cached get" hook on a readonly property, so why
doesn't it solve the issue?Or did I misunderstand it?
I share the same understanding as you, Marc.
The cached modifier I would expect to be an attribute applicable to any function which uses another cache store similar to how it's possible in python to memorize function calls which would be a very different feature.
As earlier answered to Claude [2], I seek to write less code. To introduce a
cached
modifier voids this for no strong reason (please see “mental model” above).See above - and
init
is just once more character.I was going to respond to this point earlier, but Marc beat me to it.
An "init" hook is one more character than a get hook, is explicit over
a get hook that works differently only for readonly properties, and
is far fewer characters than the explicit "cached" modifier get hook
option.On top of that, as Claude mentioned an init hook provides the ability
to differentiate between a null property and an uninitialized property
- an init hook would only be called for uninitialized properties, so
no need for $this->foo ??= "bar”.
Hey Marc, Hey Eric,
A) Init hook
Marc,
Or did I misunderstand it?
Well, I don’t know. Everyone seems to think of init hooks (and their playing together with other hooks) differently.
Some say this, some say that. That’s the exact issue. Want an example?
Eric just agreed with your code example which has a get hook AND init hook.
class Test { public int $seek { init => random_int(0, 100); // called once on read if not initialized get => $this->seek + 100; set => $this->seek = $value + 100; } }
var_dump((new Test())->seek); // number between 100-200 OR 200-300 depending of the set hook be called once with the result of init as well.
Or did I misunderstand it?I share the same understanding as you, Marc.
But one mail before he answered to Larry:
I think, at least for readonly, you couldn't have an init hook and a
get hook, since the main objection here is to having get hooks on
readonly properties at all. On normal properties, I think that'd be
okay?
So what is it? Get hook cool, or not? And how does an init hook work exactly?
How play all combinations together? And how with readonly?
Why didn’t your example use readonly if we talk about readonly hooks?
I don’t know all that. And that’s why I have proposed what I proposed.
We have a reasonable solution for set/get, without init, right in front of us; and millions of devs could benefit from it the next release.
Eric,
why it wouldn’t be solved by an init hook? Well, because as you said, readonly get hooks would not be allowed in combination with init.
Others apparently have different opinions. So will they, will they not?
I, however, want get/set on readonly properties. And that is what I proposed here.
An init hook is not part of this proposal and I am not planning to take this on.
This RFC, however, would not block anyone from creating their own RFC for init hooks.
B) Less Code
Marc was talking about init, cache modifier and attributes in the same time.
Claude initially wanted a cache modifier on each hook.
I argue that “readonly implicates cached” is very reasonable here.
Many, many opinions. Many, many options. All have their pros, and cons. All can be attacked, and defended. :)
All I want is the below (less code; and no dealing with unrelated things just because I want to add hooks to a readonly class).
// I have a nice readonly class
final readonly class Entry
{
public function __construct(
public string $word,
public string $slug,
) {}
}
// I simply want to add a hooked-property to that readonly class
final readonly class Entry
{
public function __construct(
public string $word,
public string $slug,
public array $terms {
set(array $value) => array_map(static function (Term|array $term): Term {
return $term instanceof Term ? $term : new Term(...$term);
}, $value);
get => $this->terms; // something, something
},
) {}
}
// but I cannot. I need to:
// - make the class non-readonly,
// - add some readonly here and there,
// - deal with async visibility
// to eventually end up with this
final class Entry // cannot be readonly, annoying
{
public function __construct(
public readonly string $word, // annoying readonly
public readonly string $slug, // annoying readonly
private(set) array $terms { // requires visibility
set(array $value) => array_map(static function (Term|array $term): Term {
return $term instanceof Term ? $term : new Term(...$term);
}, $value);
get => $this->terms;
},
) {}
}
Adding hook to a readonly class really should not be THAT hard and demanding.
And for that, I believe, I provided a solution that is easy to reason about (all details in previous mails), and allows everyone to achieve what they want.
Cheers,
Nick
Well, I don’t know. Everyone seems to think of init hooks (and their playing together with other hooks) differently.
Some say this, some say that. That’s the exact issue. Want an example?Eric just agreed with your code example which has a get hook AND init hook.
class Test { public int $seek { init => random_int(0, 100); // called once on read if not initialized get => $this->seek + 100; set => $this->seek = $value + 100; } }
var_dump((new Test())->seek); // number between 100-200 OR 200-300 depending of the set hook be called once with the result of init as well.
Or did I misunderstand it?
I share the same understanding as you, Marc.
But one mail before he answered to Larry:
I think, at least for readonly, you couldn't have an init hook and a
get hook, since the main objection here is to having get hooks on
readonly properties at all. On normal properties, I think that'd be
okay?So what is it? Get hook cool, or not? And how does an init hook work exactly?
How play all combinations together? And how with readonly?
Why didn’t your example use readonly if we talk about readonly hooks?
I believe you are attempting to point out a contradiction, but as far
as I can tell my two statements are consistent - his example did not
include readonly, and it makes sense to me as-is. My prior statement
to Larry is "I think, at least for readonly, you couldn't have an init
hook and a get hook". Again, his example is not readonly, so it's
consistent.
Eric,
why it wouldn’t be solved by an init hook? Well, because as you said, readonly get hooks would not be allowed in combination with init.
Others apparently have different opinions. So will they, will they not?
I don't think others have different opinions, or at least I haven't
heard someone say that readonly get hooks should be allowed with init.
I, however, want get/set on readonly properties. And that is what I proposed here.
An init hook is not part of this proposal and I am not planning to take this on.
This RFC, however, would not block anyone from creating their own RFC for init hooks.B) Less Code
Marc was talking about init, cache modifier and attributes in the same time.
Claude initially wanted a cache modifier on each hook.I argue that “readonly implicates cached” is very reasonable here.
Many, many opinions. Many, many options. All have their pros, and cons. All can be attacked, and defended. :)
All I want is the below (less code; and no dealing with unrelated things just because I want to add hooks to a readonly class).
// I have a nice readonly class final readonly class Entry { public function __construct( public string $word, public string $slug, ) {} } // I simply want to add a hooked-property to that readonly class final readonly class Entry { public function __construct( public string $word, public string $slug, public array $terms { set(array $value) => array_map(static function (Term|array $term): Term { return $term instanceof Term ? $term : new Term(...$term); }, $value); get => $this->terms; // something, something }, ) {} }
Your example might not need a "get" hook at all, if I understand
correctly? The default behavior would make sense here (and, it seems
for at least some part of the mailing list, is the only thing that
makes sense). You would need a "set" hook, but I don't think anyone is
objecting to that. If you'd like to propose "set" hooks in isolation,
it seems like it would pass.
And for that, I believe, I provided a solution that is easy to reason about (all details in previous mails), and allows everyone to achieve what they want.
I appreciate that you are focused on solving a real-world problem, and
that you have implemented a solution that addresses the problem. I can
understand that it seems like it's in reach, and that this
conversation might feel like we're wasting time.
That said, I think a number of people have reservations with the
semantics of get hooks with readonly, and it's important that we take
the time to ensure this feature is one that makes sense to all
developers, and continues to push the language in the direction of
being more consistent and hard to misuse. In an earlier email, Larry
said, "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.)". I hope
that I'm not misconstruing what he meant, but this makes it seem
especially prudent to avoid making additional mistakes with regard to
readonly.
Hi Nick
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.
Side note: Your implementation has a bug:
class C {
public public(set) readonly int $prop {
get => $this->prop + 1;
}
}
function test($c) {
var_dump($c->prop);
}
$c = new C();
$c->prop = 1;
test($c); // int(2)
test($c); // int(3)
You likely need to dodge the fast path in the VM by not marking the
property as "SIMPLE_GET" ^2.
Sorry if this e-mail is a bit all over the place, I had trouble
structuring it in a more sensible way.
Ilija
Hi Nick
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/18757I 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 callingserialize()
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?
--Larry Garfield