[php-src] Issue #20253: Constructor property promotion and hooks
| From: | DirkGerigk | Date: | Tue, 21 Oct 2025 10:32:37 +0000 |
| Subject: | [php-src] Issue #20253: Constructor property promotion and hooks | ||
| Groups: | php.bugs | ||
| Request: | Send a blank email to php-bugs+get-251096@lists.php.net to get a copy of this message | ||
Issue: https://github.com/php/php-src/issues/20253
Author: DirkGerigk
### Description
---
**Title:** 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
<?php declare(strict_types=1);
class Example
{
public function __construct(
public private(set) DateTimeInterface $created = new DateTimeImmutable() {
set(string|DateTimeInterface $value) {
if (is_string($value)) {
$value = new DateTimeImmutable($value);
}
$this->created = $value;
}
},
) {
// Within the constructor, I can set a string
$this->created = '2025-01-01';
}
}
var_export(new Example(new DateTimeImmutable()));
```
### Problem
In the current implementation, I am required to pass a
DateTimeInterface into the
constructor. My alternatives are:
* **A)** Change the constructor parameter to nullable:
```php
?DateTimeInterface $created = null
```
But then I must always handle null later on—something I might want to avoid,
especially since the get hook should always return a DateTimeInterface.
* **B)** Set a default value directly:
```php
DateTimeInterface $created = new DateTimeImmutable()
```
However, this risks creating unnecessary instances (one via the constructor, another via the
set hook). Alternatively, calling it like new Example(new
DateTimeImmutable($date)) leads to code duplication.
### Conclusion
**Constructor-promoted property hooks should be handled differently from regular property hooks.**
I propose the following changes:
---
### ✅ Proposed Behavior
1. **Use set(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.
2. **Allow the raw property value to hold any type accepted by the set hook**, not just
the final type.
This enables:
* Lazy conversion (e.g., string → DateTimeImmutable in the get hook)
* Simplified debugging via tools like get_mangled_object_vars()
* Deferring heavy instantiations until the value is actually accessed
---
### 🔧 Feature Request
Since the documentation already shows normalization inside set (like using
strtoupper() or converting a string to DateTimeImmutable), it seems
consistent and useful to also allow type casts like string → int, or
similar.
There should be **no practical difference** between:
* Using a string to construct an object (new DateTimeImmutable($string))
* Or converting a primitive type to the expected type in the setter
Both are valid forms of input processing.
Additionally, deferring instantiation until the value is accessed (lazy loading) would improve
performance and debugging flexibility.
---
### 🙋♂️ What I’d Like to Do
I’d like to be able to write code like this:
```php
<?php declare(strict_types=1);
class Example
{
public function __construct(
public private(set) ?DateTimeInterface $created = null {
get {
if (is_null($this->created)) {
$this->created = 'now';
}
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->created = '2025-01-01';
}
}
```
This would decompose to the following:
```php
class Example
{
public private(set) DateTimeInterface $created {
get {
if (is_null($this->created)) {
$this->created = 'now';
}
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(
?DateTimeInterface $created = null
) {
$this->created = $created;
}
}
```
---
### 🧩 Suggestion: Extended Hook Syntax
To support this behavior more formally, we could extend the syntax of property hooks as follows:
Instead of:
```php
get { ... }
```
Allow:
```php
get: DateTimeInterface { ... }
```
**Benefits:**
1. The get type (e.g., DateTimeInterface) is used for the decomposed
property type.
2. If no type is defined explicitly, default to the existing behavior.
---
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:
```php
class DataStruct
{
public function __construct(
public private(set) ?int $userId = null {
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.
```php
var_export(new DataStruct(...['role'=>'admin'])); #Can and will be convert to
an PHP Enum internally.
```
Let me know what you think!
Thanks 🙏
---