{"record":{"id":"646fde8c620e2b61","repo":"doctrine/orm","slug":"s-s-must-be-readonly-property","errorCode":null,"errorMessage":"%s::$%s must be readonly property","messagePattern":"(.+?)::\\$(.+?) must be readonly property","errorType":"exception","errorClass":"InvalidArgumentException","httpStatus":null,"severity":"error","filePath":"src/Mapping/PropertyAccessors/ReadonlyAccessor.php","lineNumber":21,"sourceCode":"declare(strict_types=1);\n\nnamespace Doctrine\\ORM\\Mapping\\PropertyAccessors;\n\nuse InvalidArgumentException;\nuse LogicException;\nuse ReflectionProperty;\n\nuse function sprintf;\n\nuse const PHP_VERSION_ID;\n\n/** @internal */\nclass ReadonlyAccessor implements PropertyAccessor\n{\n    public function __construct(private PropertyAccessor $parent, private ReflectionProperty $reflectionProperty)\n    {\n        if (! $this->reflectionProperty->isReadOnly()) {\n            throw new InvalidArgumentException(sprintf(\n                '%s::$%s must be readonly property',\n                $this->reflectionProperty->getDeclaringClass()->getName(),\n                $this->reflectionProperty->getName(),\n            ));\n        }\n    }\n\n    public function setValue(object $object, mixed $value): void\n    {\n        /* For lazy properties, skip the isInitialized() check\n           because it would trigger the initialization of the whole object. */\n        if (\n            PHP_VERSION_ID >= 80400 && $this->reflectionProperty->isLazy($object)\n            || ! $this->reflectionProperty->isInitialized($object)\n        ) {\n            $this->parent->setValue($object, $value);\n\n            return;","sourceCodeStart":3,"sourceCodeEnd":39,"githubUrl":"https://github.com/doctrine/orm/blob/d9b9ff73016bf598ae07515f97289ce8074e97a5/src/Mapping/PropertyAccessors/ReadonlyAccessor.php#L3-L39","documentation":"ReadonlyAccessor decorates another PropertyAccessor to allow hydrating readonly properties once (initialization) while preventing later overwrites. Its constructor asserts the wrapped ReflectionProperty is declared readonly; wrapping a non-readonly property is a programming error and throws InvalidArgumentException. PropertyAccessorFactory only wraps actually-readonly properties, so this fires on direct/custom instantiation.","triggerScenarios":"Directly calling new ReadonlyAccessor($parentAccessor, $reflectionProperty) where the property lacks the readonly modifier; custom property-accessor chains that wrap every property unconditionally; code copied from PropertyAccessorFactory without the isReadOnly() branch.","commonSituations":"Custom bundles building their own accessor pipelines; removing 'readonly' from an entity property while a cached/custom accessor chain still wraps it; experiment code that assumes the guard is optional.","solutions":["Only wrap properties where $reflectionProperty->isReadOnly() is true; add that check before constructing.","Prefer PropertyAccessorFactory::createPropertyAccessor(), which applies the wrapping conditionally.","If you removed the readonly modifier from the property intentionally, also remove the ReadonlyAccessor wrapper from your chain."],"exampleFix":"// before\n$accessor = new ReadonlyAccessor($inner, $refl); // $refl is not readonly -> InvalidArgumentException\n\n// after\n$accessor = $refl->isReadOnly()\n    ? new ReadonlyAccessor($inner, $refl)\n    : $inner;","handlingStrategy":"validation","validationCode":"$accessor = $reflectionProperty->isReadOnly()\n    ? new ReadonlyAccessor($inner, $reflectionProperty)\n    : $inner;","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Delegate accessor construction to PropertyAccessorFactory.","Re-check property modifiers whenever you hand-build accessor chains.","Treat @internal classes as unstable: wrap them behind your own factory."],"tags":["doctrine-orm","property-accessor","readonly","internal-api"],"backgroundTag":"readonly-property-misuse","analyzedSha":"d9b9ff73016bf598ae07515f97289ce8074e97a5","analyzedAt":"2026-08-21T06:13:15.863Z","schemaVersion":2},"datasetVersion":"2026-08-21T11:28:35.574Z"}