Skip to content

Cycle ORM v2 incompatible with PHP 8.4 asymmetric visibilityΒ #551

@Shelamkoff

Description

@Shelamkoff

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(); // Error

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

LazyGhostEntityFactory.php
LazyGhostMapper.php

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Status

    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions