Skip to content

Constructor property promotion and hooks #20253

@DirkGerigk

Description

@DirkGerigk

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 the get hook should always return a DateTimeInterface.

  • 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 like new 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:

  1. 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
  1. 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 using strtoupper() or converting a string to DateTimeImmutable), it seems consistent and useful to also allow type casts like stringint, 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 the get 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
  2. Allow return type for get hook (only in the constructor)

    • get: DateTimeImmutable {}

    This allows safe decomposing of types.

  3. 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?

  1. 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.
  2. I would also not do something like 'strtoupper()' in the set-hook, because then the value basically is not raw anymore.
  3. 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 🙏


Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions