-
- Notifications
You must be signed in to change notification settings - Fork 84
Description
No duplicates 🥲.
- I have searched for a similar issue in our bug tracker and didn't find any solutions.
What happened?
Hi. I ran into a hydration issue when using PHP 8.4 asymmetric visibility (private(set)) with Cycle ORM's Mapper.
The problem is in ClassPropertiesExtractor — it checks isPublic() to decide how to hydrate a property, but in PHP 8.4 a private(set) property reports itself as public (because read access is public). This causes the hydrator to skip Closure::bind for these properties, and the fallback direct assignment silently fails since set access is private. The properties end up uninitialized.
I wrote up a detailed analysis with line references, a reproduction case, a minimal hot-fix (checking isPrivateSet()/isProtectedSet() in the extractor), and a more comprehensive approach using PHP 8.4 lazy ghost objects that also addresses the final class limitation. Everything is in the attached file.
Environment
- Cycle ORM: v2.13.1
- PHP: 8.4.5
- OS: Windows 10
Description
Cycle ORM's Mapper + ProxyEntityFactory + ClosureHydrator pipeline fails when entity classes use PHP 8.4 asymmetric visibility (private(set)).
Additionally, ProxyEntityFactory has a long-standing limitation: it cannot handle final classes, which is resolved by the proposed solution below.
Bug: private(set) properties silently fail to hydrate
Root cause
ClassPropertiesExtractor::extract() uses ReflectionProperty::isPublic() to classify properties:
// ClassPropertiesExtractor.php:35 $class = $property->isPublic() ? PropertyMap::PUBLIC_CLASS : $className;In PHP 8.4, private(set) string $login has public read visibility. isPublic() returns true. The property is classified as PUBLIC_CLASS (empty string '').
ClosureHydrator::setEntityProperties() skips PUBLIC_CLASS properties:
// ClosureHydrator.php:59-62 foreach ($properties as $class => $props) { if ($class === '') { continue; // <-- skips all private(set) properties } Closure::bind(...)(...); }Skipped properties fall through to the fallback loop in hydrate():
// ClosureHydrator.php:40 @$object->{$property} = $value;This fails silently because the set visibility is private — external assignment is not allowed.
Result
private(set) properties remain uninitialized after hydration. Accessing them throws:
Typed property App\User\Domain\User::$uuid must not be accessed before initialization Fix needed
ClassPropertiesExtractor::extract() should check set visibility, not just read visibility:
// PHP 8.4 added: ReflectionProperty::isPrivateSet(), isProtectedSet() if ($property->isPublic()) { if (method_exists($property, 'isPrivateSet') && $property->isPrivateSet()) { $class = $className; // treat as private for hydration } elseif (method_exists($property, 'isProtectedSet') && $property->isProtectedSet()) { $class = $className; } else { $class = PropertyMap::PUBLIC_CLASS; } } else { $class = $className; }Limitation: ProxyEntityFactory cannot handle final classes
ProxyEntityFactory::defineClass() generates a runtime subclass via eval():
// ProxyEntityFactory.php:152-154 $reflection = new \ReflectionClass($class); if ($reflection->isFinal()) { throw new \RuntimeException("The entity `{$class}` class is final and can't be extended."); }Any entity declared as final class throws an exception. This is an inherent limitation of the proxy-based approach — it requires subclassing, which final prevents.
Reproduction
// Entity using PHP 8.4 features class User { public function __construct( #[Column(type: 'binary', typecast: 'uuid', size: 16)] private(set) readonly UuidInterface $uuid, #[Column(type: 'string', size: 64)] private(set) string $login { set => trim($value); }, ) {} public function getId(): string { return $this->uuid->toString(); // throws: must not be accessed before initialization } } // Load user from database $user = $orm->getRepository(User::class)->findByPK(1); $user->getId(); // ErrorProposed solutions
Hot-fix: patch ClassPropertiesExtractor (bug only)
Minimal change in ClassPropertiesExtractor::extract() — use PHP 8.4's ReflectionProperty::isPrivateSet() / isProtectedSet() to detect asymmetric visibility:
// Before: $class = $property->isPublic() ? PropertyMap::PUBLIC_CLASS : $className; // After: $class = $property->isPublic() && !$this->hasRestrictedSet($property) ? PropertyMap::PUBLIC_CLASS : $className; // ... private function hasRestrictedSet(\ReflectionProperty $property): bool { if (PHP_VERSION_ID < 80400) { return false; } return $property->isPrivateSet() || $property->isProtectedSet(); }Limitations: Fixes the bug only. final classes limitation remains. Also does not address (array) cast in ProxyEntityFactory::entityToArray() which may mangle private(set) property names and cannot extract virtual properties (hook-only, no backing store).
Proper fix: Replace ProxyEntityFactory with PHP 8.4 Lazy Ghost Objects
PHP 8.4 introduced ReflectionClass::newLazyGhost() — a native mechanism for creating objects without calling the constructor, with lazy initialization support. This solves the bug and the final class limitation, and provides a cleaner architecture:
| Problem | ProxyEntityFactory | Lazy Ghost |
|---|---|---|
private(set) hydration | isPublic() misclassifies → fallback assignment fails | setRawValueWithoutLazyInitialization() bypasses all visibility |
final classes | eval() subclass → fatal error | Ghost IS the original class, no subclassing |
| Data extraction | (array) cast — mangles names, skips virtual props | ReflectionProperty::getValue() — works with any visibility |
get_class() | Returns proxy class name | Returns real entity class name |
EntityProxyInterface | Required for lazy relations | Not needed — ghost initializer handles laziness |
LazyGhostEntityFactory — drop-in replacement for ProxyEntityFactory
<?php declare(strict_types=1); namespace App\Cycle\Mapper; use Cycle\ORM\Reference\ReferenceInterface; use Cycle\ORM\Relation\ActiveRelationInterface; use Cycle\ORM\RelationMap; /** * Entity factory using PHP 8.4 lazy ghost objects. * * Replaces Cycle ORM's ProxyEntityFactory which is incompatible with PHP 8.4: * - private(set) properties (isPublic() returns true, hydrator skips Closure::bind) * - final classes (proxy cannot extend) * - (array) cast (mangles private(set) property names, skips virtual properties) * * Uses ReflectionClass::newLazyGhost() for constructor-less instantiation, * ReflectionProperty::setRawValueWithoutLazyInitialization() for hydration, * and WeakMap for tracking pending relation references. */ class LazyGhostEntityFactory { /** * Pending relation references per entity. * * @var \WeakMap<object, array<string, array{ref: ReferenceInterface, relation: ActiveRelationInterface}>> */ private \WeakMap $pendingRefs; /** @var array<class-string, \ReflectionClass> */ private array $reflectionCache = []; /** * Cached property lookups: false means "no usable property". * * @var array<class-string, array<string, \ReflectionProperty|false>> */ private array $propertyCache = []; /** * Cached list of extractable (non-static, non-virtual) properties per class. * * @var array<class-string, list<\ReflectionProperty>> */ private array $extractableProperties = []; public function __construct() { $this->pendingRefs = new \WeakMap(); } /** * Create an empty entity instance (without calling its constructor). * * The returned ghost object resolves pending relation references * on first access to an uninitialized property. */ public function create(RelationMap $relMap, string $sourceClass): object { $reflection = $this->getReflection($sourceClass); $ghost = $reflection->newLazyGhost($this->createInitializer($sourceClass)); $this->pendingRefs[$ghost] = []; return $ghost; } /** * Hydrate an entity with column values and relation data. * * Two-phase approach is critical for BelongsTo relations: * Cycle ORM passes both inner key (e.g. `role_id`) and the relation * (e.g. `role` as ReferenceInterface) in $data. If scalar properties * are set first, a missing ReflectionProperty for `role_id` triggers * the fallback path (`@$entity->$prop = $value`), which accesses the * ghost and fires the initializer BEFORE the relation ref is registered * in pendingRefs — leaving the relation property uninitialized forever. * * Phase 1: Register ALL relation references in pendingRefs. * Phase 2: Set column/scalar properties (safe to trigger initializer now). * * On already-initialized entities (re-hydration), relation references * are resolved immediately via ReflectionProperty::setValue(). * * @return object Hydrated entity */ public function upgrade(RelationMap $relMap, object $entity, array $data): object { $reflection = $this->getReflection($entity::class); $relations = $relMap->getRelations(); $hasPendingRefs = false; $isLazyUninitialized = $reflection->isUninitializedLazyObject($entity); // Phase 1: Register pending relation references BEFORE touching any properties. // This ensures the ghost initializer has all refs available if triggered // by a fallback property write (e.g. role_id triggering ghost init). foreach ($data as $property => $value) { $relation = $relations[$property] ?? null; if ($relation === null || !$value instanceof ReferenceInterface) { continue; } if ($isLazyUninitialized) { $pending = $this->pendingRefs[$entity] ?? []; $pending[$property] = [ 'ref' => $value, 'relation' => $relation, ]; $this->pendingRefs[$entity] = $pending; $hasPendingRefs = true; } else { // Re-hydration on already initialized entity — resolve immediately $resolved = $relation->collect($relation->resolve($value, true)); $prop = $this->getProperty($reflection, $property); if ($prop !== null && !($prop->isReadOnly() && $prop->isInitialized($entity))) { $prop->setValue($entity, $resolved); } } } // Phase 2: Set column/scalar properties foreach ($data as $property => $value) { if (isset($relations[$property])) { continue; } $prop = $this->getProperty($reflection, $property); if ($prop !== null) { if ($prop->isReadOnly() && $prop->isInitialized($entity)) { continue; } $prop->setRawValueWithoutLazyInitialization($entity, $value); } else { // Dynamic property or virtual — fallback (matches ClosureHydrator behavior) try { @$entity->{$property} = $value; } catch (\Throwable) { // Skip silently } } } // If ghost is still uninitialized and no pending refs, mark as initialized if ($isLazyUninitialized && !$hasPendingRefs) { $reflection->markLazyObjectAsInitialized($entity); } return $entity; } /** * Extract all non-relation property values. * * @return array<string, mixed> */ public function extractData(RelationMap $relMap, object $entity): array { $relations = $relMap->getRelations(); $result = []; foreach ($this->getExtractableProperties($entity::class) as $prop) { $name = $prop->getName(); if (isset($relations[$name])) { continue; } if (!$prop->isInitialized($entity)) { continue; } $result[$name] = $prop->getValue($entity); } return $result; } /** * Extract relation values. * * For pending (unresolved) references, returns the ReferenceInterface * without triggering resolution (preserves laziness for ORM internals). * * @return array<string, mixed> */ public function extractRelations(RelationMap $relMap, object $entity): array { $result = []; $pending = $this->pendingRefs[$entity] ?? []; $reflection = $this->getReflection($entity::class); foreach ($relMap->getRelations() as $name => $relation) { if (isset($pending[$name])) { $result[$name] = $pending[$name]['ref']; continue; } if ($reflection->hasProperty($name)) { $prop = $reflection->getProperty($name); if (!$prop->isStatic() && $prop->isInitialized($entity)) { $result[$name] = $prop->getValue($entity); } } } return $result; } /** * Create the ghost initializer closure for a given class. * * When any uninitialized property is accessed, the initializer: * 1. Resolves ALL pending relation references for the entity * 2. Sets resolved values via setRawValueWithoutLazyInitialization() * 3. Clears pending refs (WeakMap entry) */ private function createInitializer(string $class): \Closure { return function (object $entity) use ($class): void { $refs = $this->pendingRefs[$entity] ?? []; $reflection = $this->getReflection($class); foreach ($refs as $name => $info) { $resolved = $info['relation']->collect( $info['relation']->resolve($info['ref'], true), ); $reflection->getProperty($name)->setRawValueWithoutLazyInitialization($entity, $resolved); } unset($this->pendingRefs[$entity]); }; } private function getReflection(string $class): \ReflectionClass { return $this->reflectionCache[$class] ??= new \ReflectionClass($class); } /** * Get a usable ReflectionProperty for hydration/extraction. * * Returns null for non-existent, static, or virtual (hook-only) properties. * Results are cached per class+property name. */ private function getProperty(\ReflectionClass $reflection, string $name): ?\ReflectionProperty { $className = $reflection->getName(); if (!\array_key_exists($name, $this->propertyCache[$className] ?? [])) { $this->propertyCache[$className][$name] = $this->resolveProperty($reflection, $name); } $cached = $this->propertyCache[$className][$name]; return $cached === false ? null : $cached; } /** * Get cached list of extractable properties (non-static, non-virtual). * * @return list<\ReflectionProperty> */ private function getExtractableProperties(string $class): array { if (!isset($this->extractableProperties[$class])) { $reflection = $this->getReflection($class); $properties = []; foreach ($reflection->getProperties() as $prop) { if ($prop->isStatic() || $prop->isVirtual()) { continue; } $properties[] = $prop; } $this->extractableProperties[$class] = $properties; } return $this->extractableProperties[$class]; } private function resolveProperty(\ReflectionClass $reflection, string $name): \ReflectionProperty|false { if (!$reflection->hasProperty($name)) { return false; } $prop = $reflection->getProperty($name); if ($prop->isStatic()) { return false; } if ($prop->isVirtual()) { return false; } return $prop; } }LazyGhostMapper — drop-in replacement for Mapper
<?php declare(strict_types=1); namespace App\Cycle\Mapper; use Cycle\ORM\Mapper\DatabaseMapper; use Cycle\ORM\Mapper\Traits\SingleTableTrait; use Cycle\ORM\ORMInterface; use Cycle\ORM\SchemaInterface; /** * Mapper using PHP 8.4 lazy ghost objects instead of Cycle ORM proxy classes. * * Structurally identical to Cycle\ORM\Mapper\Mapper but delegates entity * creation and hydration to LazyGhostEntityFactory instead of ProxyEntityFactory. * * Supports single-table inheritance via SingleTableTrait. */ class LazyGhostMapper extends DatabaseMapper { use SingleTableTrait; /** @var class-string */ protected string $entity; protected array $children = []; public function __construct( ORMInterface $orm, private LazyGhostEntityFactory $entityFactory, string $role, ) { parent::__construct($orm, $role); $this->schema = $orm->getSchema(); $this->entity = $this->schema->define($role, SchemaInterface::ENTITY); $this->children = $this->schema->define($role, SchemaInterface::CHILDREN) ?? []; $this->discriminator = $this->schema->define($role, SchemaInterface::DISCRIMINATOR) ?? $this->discriminator; } public function init(array $data, ?string $role = null): object { $class = $this->resolveClass($data, $role); return $this->entityFactory->create($this->relationMap, $class); } public function hydrate(object $entity, array $data): object { $this->entityFactory->upgrade($this->relationMap, $entity, $data); return $entity; } public function extract(object $entity): array { return $this->entityFactory->extractData($this->relationMap, $entity) + $this->entityFactory->extractRelations($this->relationMap, $entity); } public function fetchFields(object $entity): array { $values = \array_intersect_key( $this->entityFactory->extractData($this->relationMap, $entity), $this->columns + $this->parentColumns, ); return $values + $this->getDiscriminatorValues($entity); } public function fetchRelations(object $entity): array { return $this->entityFactory->extractRelations($this->relationMap, $entity); } }No changes to DatabaseMapper, RelationMap, entity classes, or any other ORM internals.
Impact
Any project using private(set) or final entity classes on PHP 8.4+ with Cycle ORM Mapper is affected. These are standard PHP 8.4 features and will become increasingly common.
Metadata
Metadata
Assignees
Labels
Type
Projects
Status