Skip to content

6.0 - Entity props with hooks#19381

Open
ADmad wants to merge 21 commits into
6.xfrom
6.0-entity-props-with-hooks
Open

6.0 - Entity props with hooks#19381
ADmad wants to merge 21 commits into
6.xfrom
6.0-entity-props-with-hooks

Conversation

@ADmad
Copy link
Copy Markdown
Member

@ADmad ADmad commented Apr 5, 2026

Refs #19380, enhanced version of #19313. The key difference between the 2 PRs is the use of reflection to support use of hooked properties with asymmetric visibility.

This implementation allows having clean entity classes like:

class User extends Entity
{
    public protected(set) int $id;
    public protected(set) ?string $email;
    public protected(set) bool $is_active = false;
    public protected(set) ?string $first_name;
    public protected(set) ?string $last_name;

    public protected(set) ?string $first_name {
        set(?string $value) {
            $this->first_name = $value ? trim(ucwords($value)) : null;
        }
    }

    public protected(set) ?string $password {
        set(?string $value) {
            $this->password = $value ? password_hash($value, PASSWORD_DEFAULT) : null;
        }
    }

    public string $full_name {
        get => $this->first_name . ' ' . $this->last_name;
    }
}

Advantages

  • No docblock required for entity properties. Docblock would not be required for "get" since it's public but would be for using assignment like $entity->foo = 'val'; since the set hook is protected.
  • Using actual typed properties gives you proper type enforcement.
  • With asymmetric visibility reading the property directly reads the value instead of going through magic __get(). Writing a value still goes through magic __set() to ensure features like dirty tracking etc. continue to work.
    Properties can also be declared without asymmetric visibility as protected string $foo.
  • Storing the values using properties instead of a fields array is more memory efficient.

Why is reflection needed

  • Our current entity implementation skips the use of setter (_setFoo()) when the entity is constructed (from the data fetched from db). PHP's set hook can be skipped only through reflection.
  • The current hasValue() differentiates between "field not set" vs "value set to null", which is not possible for properties without using ReflectedProperty::isInitialized().

What about the overhead of reflection

  • Currently an in memory cached of reflected properties is maintained through a static property.
  • The reflection data can be be further cached using the PhpEngine cache engine, which can be opcache optimized. (This is yet to be added). This would make any overhead added by use of reflection negligible and the benefits achieved greatly out weight it.

P.S. Full backwards compatibility is maintained with 5.x, so the new usage with properties become opt-in for those upgrading.

ADmad added 20 commits April 4, 2026 18:33
It ensures EntityTrait::$restrictProperties stays in sync with the trait's properties
EntityInterface::has() treats properties set to null different than
uninitialized properties
This allows using isset() instead of in_array()
- Convert $originalFields to keyed map for O(1) isset() lookups
- Simplify setOriginalField() using array_combine and += merge
- Swap branches in setHidden/setVirtual/setDirty for common path first
- Use truthy checks instead of === true/false comparisons
- Hoist $asOriginal in patch() before loop
- Pass $this->propertyFields directly in clean()
- Improve patch() phpdoc param type
…erty hooks

Implement Phase 2 of the concrete entity properties RFC:

- Add per-class static $reflectionCache for ReflectionProperty instances
- Add getReflectionProperty() for cached reflection lookups
- Add setRawValue()/getRawValue() to bypass PHP 8.4 property hooks
- Update propertyExists() to use reflection cache
- Update patch() to use setRawValue() when setter is false (ORM hydration)
- Update isModified() to use getRawValue() for dirty checking
- Update getOriginalValues(), hasErrors(), getErrors(), __debugInfo()
  to use getRawValue() for consistent raw access
- Add clearReflectionCache() for testing convenience
- Add reflectionCache to restricted properties list
- Check dynamicFields before reflection in getRequiredOrFail(),
  isModified(), setRawValue(), getRawValue()
- Hoist $ref resolution in patch() loop to eliminate redundant
  propertyExists()/getRawValue()/setRawValue() calls per field
- Add isset() fast path in getReflectionProperty() for cache hits
- Convert $originalFields from list to keyed map for O(1) isset()
  lookups instead of O(n) in_array() in isOriginalField()
Verify that restricted (infrastructure) property names cannot be
accessed, set, or unset through entity public API methods, and that
setting/unsetting fields named after restricted properties stores
them as dynamic fields without corrupting internal state.
@ADmad ADmad added this to the 6.0 milestone Apr 5, 2026
@rochamarcelo
Copy link
Copy Markdown
Contributor

Are you planning to deprecate the _fields property for CakePHP 5 in favor of dynamicFields or maybe add a new rector for upgrade?

@ADmad
Copy link
Copy Markdown
Member Author

ADmad commented Apr 7, 2026

Are you planning to deprecate the _fields property for CakePHP 5 in favor of dynamicFields or maybe add a new rector for upgrade?

No I am not. This is not a property which user needs to use directly, so I don't see any need to deprecate or provide a rector.

@LordSimal
Copy link
Copy Markdown
Contributor

The docblock "problem" can for sure be easily automated/fixed with dereuromarks ide-helper.
I very much like it 👍🏻

But how would a entity look like for fields which are already present in the EntityTrait?

I can see that the trait handles it properly via the restrictedProperties array, but does that mean those props are only defined (like currently) via the docblock?

@rochamarcelo
Copy link
Copy Markdown
Contributor

What happens when we set properties in a Entity method?

public function doSomething()
{
    $this->timestamp = new Date();
    $this->active = true;
}

$entity->doSomething();

@ADmad
Copy link
Copy Markdown
Member Author

ADmad commented Apr 10, 2026

I can see that the trait handles it properly via the restrictedProperties array, but does that mean those props are only defined (like currently) via the docblock?

If you have fields names which overlap the properties names of EntityTrait then they can only be implemented as dynamic fields with docblock.

What happens when we set properties in a Entity method?

Setting a property within the entity as $this->foo will by pass the magic __set() and hence things like dirty tracking etc. will be skipped. So within the entity one must use $this->set() to set properties. This is something which will have to be documented and users be mindful of.

@rochamarcelo
Copy link
Copy Markdown
Contributor

Based on that I'm planning to work on a PHPStan Rule to detect the use of $this->foo on real entity properties.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants