Skip to content

RFC – 6.0 – Concrete Entity Properties with Property Hook Support #19380

@ADmad

Description

@ADmad

Refs #18201, #19313

Summary

This RFC proposes allowing CakePHP entities to use concrete typed class properties as field storage instead of the existing $fields array bag, with first-class support for PHP 8.4 property hooks and asymmetric visibility. A ReflectionProperty caching layer ensures that hydration from the database bypasses property hooks, preserving correctness and performance.

Motivation

  • Type safety at the property level: Entities today store all field values in a generic array $fields. Concrete typed properties give IDE autocompletion, static analysis (PHPStan/Psalm), and runtime type checking out of the box.
  • PHP 8.4 property hooks: PHP 8.4 introduced get/set property hooks as a native replacement for the _getField()/_setField() accessor convention. Hooks are the idiomatic PHP way to intercept property access going forward.
  • Asymmetric visibility: PHP 8.4's public private(set) (and similar combinations) lets entities expose read access while restricting writes—perfect for immutable-after-hydration patterns and DTOs.
  • Hydration correctness: When the ORM hydrates an entity from database results, setter hooks must not fire—the data is already validated/canonical. Using ReflectionProperty::setRawValue() bypasses hooks and respects asymmetric visibility.
  • Performance: Caching ReflectionProperty instances per entity class eliminates repeated reflection overhead during bulk hydration (e.g., marshalling 1 000 rows).

Design Goals

  1. Backwards compatible: The existing dynamic-field ($fields array) behavior remains the default. Concrete properties are opt-in per entity class.
  2. Property hooks replace _get/_set accessors: Entities with concrete properties should use PHP 8.4 hooks. The legacy _getField()/_setField() convention still works for dynamic fields and entities that haven't migrated.
  3. Hydration bypasses hooks: Database-to-entity hydration writes raw values using reflection, so set hooks are not triggered during result marshalling.
  4. Dirty tracking works transparently: Whether a field is backed by a concrete property or a dynamic field, isDirty(), getOriginal(), and setDirty() behave identically.
  5. Cached reflection: A per-class static cache of ReflectionProperty objects avoids rebuilding reflection on every entity instantiation.

Architecture Overview

Storage Model

Entity
├── Concrete class properties   → typed, hookable, reflected
│   (e.g. protected string $title)
│
├── $dynamicFields (array)      → untyped, legacy-compatible,
│                                  for fields without a matching property
│
├── $propertyFields (array)     → tracks which field names have been initialized
│
└── $restrictedProperties       → static list of trait-internal property names
                                   that must never be treated as entity fields

ReflectionProperty Cache

A static per-class cache eliminates reflection overhead:

trait EntityTrait
{
    /**
     * Per-class cache of ReflectionProperty instances for concrete field properties.
     *
     * @var array<class-string, array<string, \ReflectionProperty>>
     */
    protected static array $reflectionCache = [];

    /**
     * Get cached ReflectionProperty for a concrete field property.
     *
     * Returns null if the property doesn't exist or is a restricted
     * (infrastructure) property.
     */
    protected static function getReflectionProperty(string $field): ?ReflectionProperty
    {
        $class = static::class;

        if (!isset(static::$reflectionCache[$class])) {
            static::$reflectionCache[$class] = [];
        }

        if (!array_key_exists($field, static::$reflectionCache[$class])) {
            if (isset(static::$restrictedProperties[$field]) || !property_exists(static::class, $field)) {
                static::$reflectionCache[$class][$field] = null;
            } else {
                $ref = new ReflectionProperty(static::class, $field);
                // Only cache non-static, non-restricted properties
                static::$reflectionCache[$class][$field] = $ref->isStatic() ? null : $ref;
            }
        }

        return static::$reflectionCache[$class][$field];
    }
}

Raw Hydration (Bypassing Hooks)

The critical piece: during hydration, raw values are written via reflection so that set hooks do not fire:

/**
 * Set a property's raw value, bypassing any property hooks.
 *
 * Uses ReflectionProperty::setRawValue() to avoid triggering `set` hooks.
 */
protected function setRawValue(string $field, mixed $value): void
{
    $ref = static::getReflectionProperty($field);

    if ($ref === null) {
        $this->dynamicFields[$field] = $value;

        return;
    }

    $ref->setRawValue($this, $value);
}

/**
 * Get a property's raw value, bypassing any property `get` hooks.
 */
protected function getRawValue(string $field): mixed
{
    $ref = static::getReflectionProperty($field);

    if ($ref === null) {
        return $this->dynamicFields[$field] ?? null;
    }

    return $ref->getRawValue($this);
}

PHP 8.4 Property Hooks

Setter Hooks

Property hooks replace _setField() accessor methods:

// Before (CakePHP 5.x convention)
class Article extends Entity
{
    protected function _setTitle(string $value): string
    {
        return strtolower($value);
    }
}

// After (PHP 8.4 property hook)
class Article extends Entity
{
    protected string $title {
        set(string $value) {
            $this->title = strtolower($value);
        }
    }
}

Behavior during hydration: When the ORM loads an Article from the database, the set hook does not fire—the raw value is written via ReflectionProperty. When application code writes $article->title = 'FOO', the hook fires as expected.

Getter Hooks (Virtual Fields)

class Article extends Entity
{
    protected string $title;
    protected string $author_name;

    // Virtual computed property
    protected string $full_title {
        get => $this->title . ' by ' . $this->author_name;
    }
}

Combined Hooks

class User extends Entity
{
    protected string $email {
        get => $this->email;
        set(string $value) {
            $this->email = strtolower(trim($value));
        }
    }
}

PHP 8.4 Asymmetric Visibility

Asymmetric visibility lets entities expose properties for reading while restricting writes:

Read-Only After Hydration

class Article extends Entity
{
    // Public for reading, only settable internally (including from the ORM via reflection)
    public protected(set) int $id;
    public protected(set) string $title;
    public protected(set) \DateTimeImmutable $created;

    // Fully mutable
    public string $body;
}

With this pattern:

  • $article->id works everywhere (read)
  • $article->id = 5 only works from within the entity class or ORM hydration (via reflection)
  • Application code gets IDE autocompletion on public properties
  • Dirty tracking still works since __set() handles write interception for protected-set properties

DTO / Immutable Entity Pattern

class ArticleView extends Entity
{
    public private(set) int $id;
    public private(set) string $title;
    public private(set) string $body;
    public private(set) string $author_name;
}

This creates a truly immutable entity after hydration—no external code can modify fields, but the ORM's reflection-based hydration still works.

How Asymmetric Visibility Interacts with Reflection

ReflectionProperty::setValue() bypasses visibility restrictions entirely. This means:

  • public private(set) properties can be written by the ORM during hydration via ReflectionProperty::setValue() even though application code cannot
  • private(set) properties can be written by the framework during marshalling
  • No special handling is needed beyond the existing reflection-based hydration path

Entity Constructor & Hydration Path

Constructor (Application Code)

When application code creates an entity, property hooks do fire:

$article = new Article([
    'title' => 'Hello World',   // set hook fires → stored as 'hello world'
    'body' => 'Content',
]);

This goes through Entity::__construct()patch()$this->{$name} = $value which triggers hooks.

ORM Hydration (Database Results)

When the ORM hydrates from query results, hooks are bypassed:

// Internal ORM marshalling path
$entity = new Article([], ['markClean' => true, 'markNew' => false]);
// Hydration via setRawValue — hooks do NOT fire
foreach ($row as $field => $value) {
    $entity->setRawValue($field, $value);
    $entity->propertyFields[$field] = $field;
}

The markClean + raw-value path ensures:

  1. No setter hooks fire (data is canonical from the DB)
  2. No dirty flags are set
  3. Original values are correctly tracked
  4. Type coercion is the DB driver's responsibility, not the entity's

Explicit Opt-in for Hooks During Construction

For entities that want hooks to fire even during new Entity([...]):

$entity = new Article(['title' => 'FOO'], ['useSetters' => true]);
// 'useSetters' => true (the default) means patch() is used, which triggers hooks

For bulk insertion without hooks:

$entity = new Article(['title' => 'already-clean'], ['useSetters' => false]);
// Bypasses hooks via the internal raw path

Dirty Tracking with Concrete Properties

How It Works

The $propertyFields array tracks which field names have been initialized on the entity. Combined with $dirty and $original, this provides full change tracking:

$article = new Article(['title' => 'Hello'], ['markClean' => true]);

$article->title = 'World';
$article->isDirty('title');       // true
$article->getOriginal('title');   // 'Hello'
$article->getDirty();             // ['title']

isModified() with Concrete Properties

The isModified() method handles both concrete and dynamic properties:

protected function isModified(string $field, mixed $value): bool
{
    $ref = static::getReflectionProperty($field);
    if ($ref !== null) {
        if (!$ref->isInitialized($this)) {
            return true;
        }
        $existing = $ref->getRawValue($this);  // bypass get hooks
    } else {
        if (!array_key_exists($field, $this->dynamicFields)) {
            return true;
        }
        $existing = $this->dynamicFields[$field] ?? null;
    }

    // Scalars and nulls compared by identity
    if (($value === null || is_scalar($value)) && $existing === $value) {
        return false;
    }

    // Objects compared by equality (except entities)
    if (
        is_object($value)
        && is_object($existing)
        && !($value instanceof EntityInterface)
        && $existing == $value
    ) {
        return false;
    }

    return true;
}

Key point: isModified() uses getRawValue() (bypassing get hooks) so that virtual/computed properties don't interfere with dirty checks.


Restricted Properties

The $restrictedProperties static array prevents infrastructure properties from being treated as entity fields:

protected static array $restrictedProperties = [
    'dynamicFields' => 'dynamicFields',
    'propertyFields' => 'propertyFields',
    'reflectionCache' => 'reflectionCache',
    'original' => 'original',
    'originalFields' => 'originalFields',
    'hidden' => 'hidden',
    'virtual' => 'virtual',
    'dirty' => 'dirty',
    'accessors' => 'accessors',
    'new' => 'new',
    'errors' => 'errors',
    'invalid' => 'invalid',
    'patchable' => 'patchable',
    'registryAlias' => 'registryAlias',
    'hasBeenVisited' => 'hasBeenVisited',
    'requireFieldPresence' => 'requireFieldPresence',
    'restrictedProperties' => 'restrictedProperties',
];

propertyExists() checks this list before treating a class property as a field:

protected function propertyExists(string $field): bool
{
    return static::getReflectionProperty($field) !== null;
}

Usage Examples

Basic Entity with Typed Properties

namespace App\Model\Entity;

use Cake\ORM\Entity;

class Article extends Entity
{
    protected int $id;
    protected string $title;
    protected string $body;
    protected int $author_id;
    protected ?\DateTimeImmutable $created;
    protected ?\DateTimeImmutable $modified;
}

Entity with Property Hooks

class Article extends Entity
{
    protected int $id;

    protected string $title {
        set(string $value) {
            $this->title = strip_tags($value);
        }
    }

    protected string $slug {
        get => strtolower(str_replace(' ', '-', $this->title));
    }

    protected string $body;
    protected int $author_id;
}

Entity with Asymmetric Visibility

class Article extends Entity
{
    public protected(set) int $id;
    public protected(set) string $title;
    public protected(set) string $body;

    // Fully public for reading, mutation only through entity methods
    public function updateTitle(string $title): void
    {
        $this->set('title', $title);  // Goes through dirty tracking
    }
}

Mixed: Dynamic + Concrete Fields

Entities can mix concrete properties with dynamic fields. Any field that doesn't have a matching class property is stored in $dynamicFields:

class Article extends Entity
{
    protected int $id;
    protected string $title;
    // 'body' has no property → stored dynamically
    // 'custom_field' has no property → stored dynamically
}

$article = new Article([
    'id' => 1,
    'title' => 'Hello',
    'body' => 'World',          // dynamic field
    'custom_field' => 'value',  // dynamic field
]);

$article->title;         // 'Hello' (concrete)
$article->body;          // 'World' (dynamic, via __get)
$article->custom_field;  // 'value' (dynamic, via __get)

Performance Considerations

ReflectionProperty Caching

The per-class static $reflectionCache is populated lazily and persists for the process lifetime:

  • First entity of a class: ReflectionProperty objects are created and cached for each field accessed
  • Subsequent entities: Cache hit via isset() — effectively zero overhead
  • Benchmarks expected: Hydrating 1 000 entities should show negligible reflection overhead compared to the current array-based approach, since ReflectionProperty::setValue() is a C-level call

Memory

Each cached ReflectionProperty is a lightweight object. For an entity with 10 concrete fields, the static cache holds ~10 objects per entity class—negligible in any application.


Migration Path

Phase 1: Concrete Properties (Current PR #19313)

  • Entities can define concrete typed properties
  • $fields array replaced by $dynamicFields + $propertyFields tracking
  • propertyExists() + $restrictedProperties distinguish fields from infrastructure
  • Property hooks work via $this->{$name} = $value in patch()

Phase 2: Reflection-Based Hydration (This RFC Addition)

  • Add $reflectionCache, getReflectionProperty(), setRawValue(), getRawValue()
  • ORM marshalling uses setRawValue() to bypass hooks during hydration
  • isModified() uses getRawValue() to bypass get hooks during dirty checks
  • getRequiredOrFail() still goes through hooks for application-facing reads

Phase 3: Asymmetric Visibility Support

  • Document patterns for public protected(set) and public private(set)
  • Reflection-based hydration already handles asymmetric visibility naturally
  • Guide users on immutable entity patterns

Phase 4: Deprecate Legacy Accessors

  • Deprecate _getField()/_setField() convention in favor of property hooks
  • Provide migration tooling (rector rules) to convert accessors to hooks
  • Eventually remove accessor support in a future major version

We can consider deprecating existing convention based settor/accessor sometime in the future. For now we keep it for friction free upgrade from 5.x


Backwards Compatibility

  • Fully backwards compatible. Existing entities with no concrete properties continue to work exactly as before—all fields go into $dynamicFields (functionally identical to the old $fields).
  • The _getField()/_setField() accessor convention continues to work for both concrete and dynamic fields.
  • __get()/__set() magic methods continue to intercept property access. For concrete protected properties, PHP routes access through the magic methods when accessed from outside the class, so dirty tracking is preserved.
  • Entities with public properties bypass __get()/__set() and therefore bypass dirty tracking. The RFC recommends protected or public protected(set) for properties that need tracking.

Open Questions

  1. Should setRawValue()/getRawValue() be public? Making them public allows Table classes and custom marshalling logic to bypass hooks. Making them protected keeps the API surface smaller.

  2. Should the framework detect hooked properties automatically? PHP 8.4's ReflectionProperty::hasHooks() / ReflectionProperty::getHook() could be used to automatically choose between raw and hooked paths, but explicit control seems simpler.

  3. Cache invalidation for testing: The static $reflectionCache persists across tests. Should there be a clearReflectionCache() method for testing convenience?

  4. Interaction with projectAs() / DTO casting: How should concrete-property entities interact with the proposed projectAs() feature (Add DTO projection support via projectAs() query method #19135)? The reflection cache could be shared.

  5. public properties and dirty tracking: public properties bypass __set(). Should the framework detect and warn about public entity properties, or is this an acceptable footgun for advanced users who don't need tracking?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels
    No fields configured for Enhancement.

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions