You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Backwards compatible: The existing dynamic-field ($fields array) behavior remains the default. Concrete properties are opt-in per entity class.
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.
Hydration bypasses hooks: Database-to-entity hydration writes raw values using reflection, so set hooks are not triggered during result marshalling.
Dirty tracking works transparently: Whether a field is backed by a concrete property or a dynamic field, isDirty(), getOriginal(), and setDirty() behave identically.
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>> */protectedstaticarray$reflectionCache = [];
/** * Get cached ReflectionProperty for a concrete field property. * * Returns null if the property doesn't exist or is a restricted * (infrastructure) property. */protectedstaticfunctiongetReflectionProperty(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 = newReflectionProperty(static::class, $field);
// Only cache non-static, non-restricted propertiesstatic::$reflectionCache[$class][$field] = $ref->isStatic() ? null : $ref;
}
}
returnstatic::$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. */protectedfunctionsetRawValue(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. */protectedfunctiongetRawValue(string$field): mixed
{
$ref = static::getReflectionProperty($field);
if ($ref === null) {
return$this->dynamicFields[$field] ?? null;
}
return$ref->getRawValue($this);
}
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
{
protectedstring$title;
protectedstring$author_name;
// Virtual computed propertyprotectedstring$full_title {
get => $this->title . ' by ' . $this->author_name;
}
}
Combined Hooks
class User extends Entity
{
protectedstring$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)publicprotected(set)int$id;
publicprotected(set)string$title;
publicprotected(set)\DateTimeImmutable$created;
// Fully mutablepublicstring$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
{
publicprivate(set)int$id;
publicprivate(set)string$title;
publicprivate(set)string$body;
publicprivate(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 = newArticle([
'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:
No setter hooks fire (data is canonical from the DB)
No dirty flags are set
Original values are correctly tracked
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 = newArticle(['title' => 'FOO'], ['useSetters' => true]);
// 'useSetters' => true (the default) means patch() is used, which triggers hooks
For bulk insertion without hooks:
$entity = newArticle(['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:
class Article extends Entity
{
publicprotected(set)int$id;
publicprotected(set)string$title;
publicprotected(set)string$body;
// Fully public for reading, mutation only through entity methodspublicfunctionupdateTitle(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
{
protectedint$id;
protectedstring$title;
// 'body' has no property → stored dynamically// 'custom_field' has no property → stored dynamically
}
$article = newArticle([
'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.
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
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.
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.
Cache invalidation for testing: The static $reflectionCache persists across tests. Should there be a clearReflectionCache() method for testing convenience?
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?
Refs #18201, #19313
Summary
This RFC proposes allowing CakePHP entities to use concrete typed class properties as field storage instead of the existing
$fieldsarray bag, with first-class support for PHP 8.4 property hooks and asymmetric visibility. AReflectionPropertycaching layer ensures that hydration from the database bypasses property hooks, preserving correctness and performance.Motivation
array $fields. Concrete typed properties give IDE autocompletion, static analysis (PHPStan/Psalm), and runtime type checking out of the box.get/setproperty hooks as a native replacement for the_getField()/_setField()accessor convention. Hooks are the idiomatic PHP way to intercept property access going forward.public private(set)(and similar combinations) lets entities expose read access while restricting writes—perfect for immutable-after-hydration patterns and DTOs.ReflectionProperty::setRawValue()bypasses hooks and respects asymmetric visibility.ReflectionPropertyinstances per entity class eliminates repeated reflection overhead during bulk hydration (e.g., marshalling 1 000 rows).Design Goals
$fieldsarray) behavior remains the default. Concrete properties are opt-in per entity class._get/_setaccessors: 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.sethooks are not triggered during result marshalling.isDirty(),getOriginal(), andsetDirty()behave identically.ReflectionPropertyobjects avoids rebuilding reflection on every entity instantiation.Architecture Overview
Storage Model
ReflectionProperty Cache
A static per-class cache eliminates reflection overhead:
Raw Hydration (Bypassing Hooks)
The critical piece: during hydration, raw values are written via reflection so that
sethooks do not fire:PHP 8.4 Property Hooks
Setter Hooks
Property hooks replace
_setField()accessor methods:Behavior during hydration: When the ORM loads an
Articlefrom the database, thesethook does not fire—the raw value is written viaReflectionProperty. When application code writes$article->title = 'FOO', the hook fires as expected.Getter Hooks (Virtual Fields)
Combined Hooks
PHP 8.4 Asymmetric Visibility
Asymmetric visibility lets entities expose properties for reading while restricting writes:
Read-Only After Hydration
With this pattern:
$article->idworks everywhere (read)$article->id = 5only works from within the entity class or ORM hydration (via reflection)__set()handles write interception for protected-set propertiesDTO / Immutable Entity Pattern
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 viaReflectionProperty::setValue()even though application code cannotprivate(set)properties can be written by the framework during marshallingEntity Constructor & Hydration Path
Constructor (Application Code)
When application code creates an entity, property hooks do fire:
This goes through
Entity::__construct()→patch()→$this->{$name} = $valuewhich triggers hooks.ORM Hydration (Database Results)
When the ORM hydrates from query results, hooks are bypassed:
The
markClean+ raw-value path ensures:Explicit Opt-in for Hooks During Construction
For entities that want hooks to fire even during
new Entity([...]):For bulk insertion without hooks:
Dirty Tracking with Concrete Properties
How It Works
The
$propertyFieldsarray tracks which field names have been initialized on the entity. Combined with$dirtyand$original, this provides full change tracking:isModified()with Concrete PropertiesThe
isModified()method handles both concrete and dynamic properties:Key point:
isModified()usesgetRawValue()(bypassinggethooks) so that virtual/computed properties don't interfere with dirty checks.Restricted Properties
The
$restrictedPropertiesstatic array prevents infrastructure properties from being treated as entity fields:propertyExists()checks this list before treating a class property as a field:Usage Examples
Basic Entity with Typed Properties
Entity with Property Hooks
Entity with Asymmetric Visibility
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:Performance Considerations
ReflectionProperty Caching
The per-class static
$reflectionCacheis populated lazily and persists for the process lifetime:ReflectionPropertyobjects are created and cached for each field accessedisset()— effectively zero overheadReflectionProperty::setValue()is a C-level callMemory
Each cached
ReflectionPropertyis 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)
$fieldsarray replaced by$dynamicFields+$propertyFieldstrackingpropertyExists()+$restrictedPropertiesdistinguish fields from infrastructure$this->{$name} = $valueinpatch()Phase 2: Reflection-Based Hydration (This RFC Addition)
$reflectionCache,getReflectionProperty(),setRawValue(),getRawValue()setRawValue()to bypass hooks during hydrationisModified()usesgetRawValue()to bypassgethooks during dirty checksgetRequiredOrFail()still goes through hooks for application-facing readsPhase 3: Asymmetric Visibility Support
public protected(set)andpublic private(set)Phase 4: Deprecate Legacy Accessors
Deprecate_getField()/_setField()convention in favor of property hooksProvide migration tooling (rector rules) to convert accessors to hooksEventually remove accessor support in a future major versionWe 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
$dynamicFields(functionally identical to the old$fields)._getField()/_setField()accessor convention continues to work for both concrete and dynamic fields.__get()/__set()magic methods continue to intercept property access. For concreteprotectedproperties, PHP routes access through the magic methods when accessed from outside the class, so dirty tracking is preserved.publicproperties bypass__get()/__set()and therefore bypass dirty tracking. The RFC recommendsprotectedorpublic protected(set)for properties that need tracking.Open Questions
ShouldsetRawValue()/getRawValue()be public? Making them public allows Table classes and custom marshalling logic to bypass hooks. Making them protected keeps the API surface smaller.Should the framework detect hooked properties automatically? PHP 8.4'sReflectionProperty::hasHooks()/ReflectionProperty::getHook()could be used to automatically choose between raw and hooked paths, but explicit control seems simpler.Cache invalidation for testing: The static
$reflectionCachepersists across tests. Should there be aclearReflectionCache()method for testing convenience?Interaction with
projectAs()/ DTO casting: How should concrete-property entities interact with the proposedprojectAs()feature (Add DTO projection support via projectAs() query method #19135)? The reflection cache could be shared.publicproperties and dirty tracking:publicproperties bypass__set(). Should the framework detect and warn aboutpublicentity properties, or is this an acceptable footgun for advanced users who don't need tracking?