doctrine/orm · error · LogicException

Attempting to change readonly property %s::$%s.

Error message

Attempting to change readonly property %s::$%s.

What it means

Doctrine wraps PHP readonly properties in ReflectionReadonlyProperty (legacy reflection mode, installed by LegacyReflectionFields) so hydration can initialize them once. setValue() only bypasses PHP's readonly restriction while the property is uninitialized, or when the new value is identical (===) to the current one (idempotent re-set during re-hydration/flush). Writing a DIFFERENT value to an already-initialized readonly property is rejected with this LogicException.

Source

Thrown at src/Mapping/ReflectionReadonlyProperty.php:46

    }

    public function getValue(object|null $object = null): mixed
    {
        return $this->wrappedProperty->getValue(...func_get_args());
    }

    public function setValue(mixed $objectOrValue, mixed $value = null): void
    {
        if (func_num_args() < 2 || $objectOrValue === null || ! $this->isInitialized($objectOrValue)) {
            $this->wrappedProperty->setValue(...func_get_args());

            return;
        }

        assert(is_object($objectOrValue));

        if (parent::getValue($objectOrValue) !== $value) {
            throw new LogicException(sprintf('Attempting to change readonly property %s::$%s.', $this->class, $this->name));
        }
    }
}

View on GitHub (pinned to d9b9ff7301)

Solutions

  1. Remove `readonly` from any property that can legitimately change (version fields, updatable or generated columns)
  2. If the value is unchanged, ensure you assign the identical value (=== strict comparison; int vs string type mismatch counts as different)
  3. Never declare #[Version] fields readonly
  4. Audit DB triggers/row-level security if refresh()/flush re-hydration sees different stored values

Example fix

// before
final class User {
    public function __construct(
        #[Column] public readonly string $name,
    ) {}
}
$u->name = 'new'; // throws on flush

// after
final class User {
    public function __construct(
        #[Column] public string $name,
    ) {}
}
Defensive patterns

Strategy: validation

Validate before calling

$rp = new ReflectionProperty($entity, $field);
if ($rp->isReadOnly() && $rp->isInitialized($entity) && $rp->getValue($entity) !== $newValue) {
    // refuse: would throw on flush
}

Type guard

function wouldOverwriteInitializedReadonly(object $entity, string $field, mixed $value): bool
{
    $rp = new ReflectionProperty($entity, $field);
    return $rp->isReadOnly() && $rp->isInitialized($entity) && $rp->getValue($entity) !== $value;
}

Try / catch

try { $em->flush(); } catch (LogicException $e) { if (str_starts_with($e->getMessage(), 'Attempting to change readonly property')) { /* drop the change or make the property mutable */ } throw $e; }

Prevention

When it happens

Trigger: flush() attempts to write a changed value to a readonly property: a #[Column] readonly field combined with #[Version] (version increments), a generated column value written back that differs, refresh()/re-hydration of a managed entity whose stored row changed (trigger/other process), or user code mutating the property via reflection before flush.

Common situations: Making all constructor-promoted entity properties readonly while still expecting updates; readonly + @Version columns; entities re-hydrated from rows modified by DB triggers or concurrent requests; refresh() after another process changed the row.

Related errors


AI-assisted analysis of doctrine/orm@d9b9ff7301 (2026-08-21). Data as JSON: /api/errors/3a16dfa2769821d5. Report an issue: GitHub.