Skip to content

Cycle ORM v2 incompatible with PHP 8.4 asymmetric visibility #551

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