-
-
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