The Situation
We were halfway through migrating a 10-year-old codebase to PHP 8.2 when we hit a wall. To modernize the app, we implemented readonly properties for our Data Transfer Objects (DTOs). This worked perfectly for simple requests, but everything crashed during a routine sync of 5,000 user records.
The application died mid-process with a specific, frustrating message:
Fatal error: Uncaught Error: Cannot modify readonly property UserProfile::$username in /var/www/html/src/Models/UserProfile.php:14
PHP 8.1's readonly modifier is strict. Once you set a value, the property locks down. You can only initialize it once, and only from within the class where it was defined. Any subsequent change—even if you try to assign the identical value—triggers a Fatal Error and stops your script immediately.
The Debugging Process
The error usually crops up during loops or state updates. In our case, the logic looked roughly like this:
class UserProfile {
public readonly string $username;
public function setUsername(string $name) {
$this->username = $name; // Only works if $username is currently uninitialized
}
}
$profile = new UserProfile();
$profile->setUsername('jdoe');
$profile->setUsername('smith'); // FATAL ERROR: The lock is already on
The debugger pointed straight to the second setUsername call. Even though the assignment happens inside a class method, the property became immutable the moment 'jdoe' was assigned. This also happens if you try to unset() a readonly property or modify it from an external scope.
Solutions to Fix the Error
1. Use Constructor Promotion
The cleanest fix is to handle initialization during object creation. Constructor promotion reduces boilerplate and ensures the property is set exactly once as the object enters memory. This is the standard approach for modern PHP DTOs.
class UserProfile {
// PHP 8.1+ shorthand
public function __construct(
public readonly string $username
) {}
}
// Usage is now safe and atomic
$profile = new UserProfile('jdoe');
2. Implement the "Wither" Pattern
What if you actually need to change a value? In an immutable world, you don't modify the existing object. Instead, you return a brand-new instance with the updated data. This keeps your original object's state predictable across your entire 10,000-line application.
class UserProfile {
public function __construct(
public readonly string $username,
public readonly string $email
) {}
public function withUsername(string $newUsername): self {
// Create a copy with the new value
return new self($newUsername, $this->email);
}
}
$profile = new UserProfile('jdoe', 'jane@example.com');
$updatedProfile = $profile->withUsername('smith');
// Original $profile remains 'jdoe'
3. Leverage PHP 8.2 Cloning
For those running PHP 8.2 or higher, there is a specific exception to the rule. You can now re-initialize readonly properties during a __clone() call. This is incredibly useful for complex objects or reports that need a fresh timestamp upon being copied.
class Report {
public function __construct(
public readonly DateTime $createdAt
) {}
public function __clone() {
// PHP 8.2 allows this one-time overwrite during cloning
$this->createdAt = new DateTime();
}
}
4. Re-evaluate the Readonly Keyword
Don't over-engineer your models. If a property like status or lastLogin changes frequently throughout a user's session, readonly is the wrong tool. Simply remove the keyword. Use private properties with standard getters and setters to maintain control without the rigid immutability of readonly.
Verification
I verify these fixes by running a small CLI script before deploying to production. My checklist includes:
- Instantiating the object and checking the initial value.
- Triggering the "Wither" method or clone.
- Using `var_dump()` to confirm the original object hasn't changed.
- Ensuring the script finishes with an exit code of 0, meaning no Fatal Errors occurred.
Key Takeaways
Readonly properties are powerful for keeping data consistent, but they require a shift in how you manage state. If you find yourself fighting this error daily, your architecture might need to move toward immutability (the Wither pattern) or simpler encapsulated properties. Stick to constructor promotion whenever possible to avoid accidental double-assignments.

