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
- Remove `readonly` from any property that can legitimately change (version fields, updatable or generated columns)
- If the value is unchanged, ensure you assign the identical value (=== strict comparison; int vs string type mismatch counts as different)
- Never declare #[Version] fields readonly
- 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
- Never mark versioned, updatable or generated columns readonly
- Keep entity integration tests that flush changed entities
- Treat readonly entity properties as truly immutable in application code
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
- hydrateRowData() not implemented by this hydrator.
- {argAlias} does not exist
- Attempting to change readonly property %s::$%s.
- This mapping is not indexed. Use %s::isIndexed() to check th
- ClassMetadata::setTable() has to be called before defining a
AI-assisted analysis of doctrine/orm@d9b9ff7301 (2026-08-21).
Data as JSON: /api/errors/3a16dfa2769821d5.
Report an issue: GitHub.