-
Notifications
You must be signed in to change notification settings - Fork 8k
Description
Feature Request: Improve Constructor-Promoted Property Hooks Behavior and Lazy Initialization
Hello,
Over the past few days, I’ve been working extensively with PHP's property hooks—especially with constructor-promoted property hooks.
Consider the following example from the PHP documentation:
🔗 https://www.php.net/manual/en/language.oop5.property-hooks.php
<?php declare(strict_types=1);
class Example
{
public function __construct(
public private(set) DateTimeInterface $created {
set (string|DateTimeInterface $value) {
if (is_string($value)) {
$value = new DateTimeImmutable($value);
}
$this->created = $value;
}
},
) {
}
}
var_export(new Example(new DateTimeImmutable()));
Problem
In the current implementation, I am required to:
-
A) pass a
DateTimeInterface
into the constructor:new Example(new DateTimeImmutable());
-
B) Change the constructor parameter to nullable:
public private(set) ?DateTimeInterface $created = null
But then I must always handle
null
later on—something I might want to avoid, especially since theget
hook should always return aDateTimeInterface
. -
C) Set a default value directly:
public private(set) DateTimeInterface $created = new DateTimeImmutable()
However, this risks creating unnecessary instances (one via the constructor, another via the
set
hook). Alternatively, calling it likenew Example(new DateTimeImmutable($date))
leads to code duplication.
Conclusion
Constructor-promoted property hooks should be handled differently from regular property hooks.
✅ Proposed Behavior
I propose the following changes:
- Use
set(null|string|DateTimeInterface $value)
to process constructor values during engine decomposition (de-sugaring).
Since constructors are called only once per instance, this hook should be triggered during construction.
- when
- a hooked property is defined with constructor property promotion
- and that hooked property has a
set
hook - and that
set
hook accepts a wider type than the property stores
- then
- the desugared version of the constructor parameter should have the type from the
set
hook rather than from the property itself
- the desugared version of the constructor parameter should have the type from the
-
Allow the raw property value to hold any type accepted by the
set
hook, not just the final type.Since the documentation already shows normalization inside
set
(like usingstrtoupper()
or converting a string toDateTimeImmutable
), it seems consistent and useful to also allow type casts likestring
→int
, or similar.But when we can hold the raw value until we use the get-hook we can:
- Lazy conversion (e.g., string →
DateTimeImmutable
in theget
hook) - Simplified debugging via tools like
get_mangled_object_vars()
- We have the
raw value
in the get-hook - Deferring heavy instantiations until the value is actually accessed
- Lazy conversion (e.g., string →
-
Allow return type for get hook (only in the constructor)
get: DateTimeImmutable {}
This allows safe decomposing of types.
-
Omit constructor parameter types when having a set-hook
- when
- a set-hook is present with wider types
- then
- disallow promoted property type
- or the types in the promoted property must be same as set-hook types definition
🙋♂️ What I’d Like to Do
I’d like to be able to write code like this:
class Example
{
public function __construct(
//No types need, because we get it from the set-hook
//OR we define the same types: public private(set) null|string|DateTimeInterface $created = null {
public private(set) $created = null {
get : DateTimeInterface {
//in the get-hook, we should only be allowed to set the real type, not e.g. $this->created = 'now';
if (is_null($this->created)) {
$this->created = new DateTimeImmutable();
}
if (is_string($this->created)) {
$this->created = new DateTimeImmutable($this->created);
}
return $this->created;
}
set(null|string|DateTimeInterface $value) {
if (is_string($value) && !is_proper_datetime_string($value)) {
throw new InvalidArgumentException("Cannot use $value for DateTimeInterface constructor");
}
// Store raw value (for introspection, etc.)
$this->created = $value;
}
},
) {
}
}
This would decompose to the following:
class Example
{
public private(set) DateTimeInterface $created {
get {
if (is_null($this->created)) {
$this->created = new DateTimeImmutable();
} else if (is_string($this->created)) {
$this->created = new DateTimeImmutable($this->created);
}
return $this->created;
}
set(null|string|DateTimeInterface $value) {
if (is_string($value) && !is_proper_datetime_string($value)) {
throw new InvalidArgumentException("Cannot use $value for DateTimeInterface constructor");
}
$this->created = $value;
}
}
public function __construct(
?string|DateTimeInterface $created = null
) {
$this->created = $created;
}
}
How would I like to use hooks?
- I would always set the raw value in the set-hook, maybe do some validation beforehand. For example we check a string value against Foo::cases(), save the raw value and create the real Enum Instance later in the get-hook.
- I would also not do something like 'strtoupper()' in the set-hook, because then the value basically is not raw anymore.
- Also, what if we throw in the get-hook. What if set-hook was call on a completely different place. Would it not be good to have the raw value, when we throw an error in the get-hook?
Conclusion
Maybe it is a good approach, to handle hooks in the constructor a little different.
I'm still thinking of use cases of hooks and where it would be good or bad.
But that hooks, placed in promoted constructor parameter, are currently blocking use-cases, because of the type, should be evaluated.
Also this example looks/feels wrong.
class Example
{
public function __construct(
public DateTimeInterface $created {
set (string|DateTimeInterface $value) {
if (is_string($value)) {
$value = new DateTimeImmutable($value);
}
$this->created = $value;
}
}
) {
}
}
// I'm forced to use an real object here
$obj = new Example(new DateTimeImmutable());
// But can just use a string one line later
$obj->created = 'now';
I would like to write more about this, but English is not my native language, so I'm not sure if I can clearly express all the thoughts I have on the topic.
But what i have in mind are simple data holder objects with auto casting, like:
class DataStruct
{
public function __construct(
public private(set) int $userId {
get : int => (int)$this->userId;
set(string|int $value) => $value;
},
...$args
) {
//do some with the unkown args, maybe optional parameter
}
}
var_export(new DataStruct(...['userId'=>'12','foo'=>'bar']));
Or when we have for example an enum field in a table, we can easy convert it to a real PHP Enum.
var_export(new DataStruct(...['role'=>'admin'])); #Can and will be convert to an PHP Enum internally.
I know this is a very complex topic. I've read the RFC, some Reddit discussions, a few articles on the subject, as well as the PHP documentation. But personally, I don't think the current handling in the constructor is very well-solved. Maybe I’ve misunderstood the overall concept of hooks, but the approach I've outlined here seems quite interesting to me.
Let me know what you think!
Thanks 🙏