Fix PHP Fatal error: Cannot modify readonly property in PHP 8.1+

intermediate🐘 PHP2026-07-08| PHP 8.1, PHP 8.2, PHP 8.3+ on any OS (Linux, Windows, macOS)

Error Message

Fatal error: Uncaught Error: Cannot modify readonly property ClassName::$propertyName
#php#readonly#oop#php8

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.

Related Error Notes