Na navigaci | Klávesové zkratky

The Hidden Surprises of PHP Readonly Properties

Picture this: data that's as stable as bedrock – set it once, and it stays that way forever. That's exactly what PHP 8.1 delivered with readonly properties. Think of it as giving your objects a safety vault – keeping their data secure from accidental changes. Let's explore how this powerful feature can streamline your code and what gotchas you need to watch out for.

Here's a quick taste of what we're talking about:

class User
{
    public readonly string $name;

    public function setName(string $name): void
    {
        $this->name = $name;  // First assignment - all OK
    }
}

$user = new User;
$user->setName('John');      // Great, name is set
echo $user->name;            // "John"
$user->setName('Jane');      // BOOM! Exception: Cannot modify readonly property

Once that name is set, it's locked in place. No accidental changes, no sneaky updates.

When is uninitialized really uninitialized?

Here's a common misconception: many developers think readonly properties must be set in the constructor. But PHP is actually much more flexible than that – you can set them at any point in an object's lifecycle, with one crucial rule: only once! Before that first assignment, they exist in a special ‘uninitialized’ state – think of it as a blank slate waiting for its first and only value.

Here's an interesting twist – readonly properties can't have default values. Why? Think about it: if they had default values, they'd essentially be constants – set at object creation and unchangeable from that point on.

Types are mandatory

When using readonly properties, you must explicitly declare their type. This isn't just PHP being picky – the ‘uninitialized’ state only works with typed variables. No type declaration means no readonly variable. Don't know the exact type? No worries – you can always fall back on mixed.

Readonly classes: Taking immutability to the next level

PHP 8.2 kicked things up a notch. Instead of securing individual properties, you can now lock down an entire class. It's like upgrading from a safe to a vault:

readonly class User
{
    public string $name;     // Automatically readonly!
    public string $email;    // Also readonly!
}

But hold on – this power comes with some strict rules:

  • Every property must have a type
  • Static properties are off-limits
  • Dynamic properties? Not happening, even with #[AllowDynamicProperties]
  • And in PHP 8.2, readonly classes could only inherit from other readonly classes (PHP 8.3 loosened this restriction)

Initialization rules: Who can set what, and when?

Here's where things get interesting. Take a look at this code:

$user = new User;
$user->name = 'John';  // BOOM! Cannot initialize readonly property from global scope

Caught you by surprise? Even though it's the first assignment, PHP won't allow it. The same goes for child classes trying to initialize their parent's readonly properties:

class Employee extends User
{
    public function setName(string $name): void
    {
        $this->name = 'EMP: ' . $name;  // BOOM! Child classes can't do this either!
    }
}

Until recently, only the defining class could initialize a readonly property. But PHP 8.4 changes the game with two major updates:

  • Child classes can now initialize readonly properties (finally!)
  • The new public(set) modifier lets you open up initialization:
class User
{
    public(set) readonly string $name;  // New flexibility in PHP 8.4
}

$user = new User;
$user->name = 'John';  // Now this works!

When readonly isn't quite readonly

Think of readonly like a secure container – while you can't swap out the container, you can still modify what's inside. This means readonly doesn't guarantee complete immutability. When you store an object in a readonly property, its internal state remains changeable:

class Settings
{
	public string $theme = 'light';
}

class Configuration
{
	public function __construct(
		public readonly Settings $settings = new Settings,
	) {
	}
}

$config = new Configuration;
$config->settings->theme = 'dark'; // This works, even though $settings is readonly!

See the catch? While you can't replace the $settings object itself, its properties are still fair game.

Arrays have their own special rules. You can't directly modify array elements because PHP sees this as changing the entire array:

class Configuration
{
    public readonly array $settings;

    public function initialize(): void
    {
        $this->settings = ['debug' => true];
        $this->settings['cache'] = true;  // BOOM! Can't do this
    }
}

But there's a clever workaround – references. If your array contains references, you can modify their values because PHP doesn't consider this a change to the array itself:

class Configuration
{
    public readonly array $settings;

    public function initialize(): void
    {
        // Reference trick
        $mode = 'development';
        $this->settings = [
            'debug' => true,
            'mode' => &$mode,  // Reference is our secret weapon!
        ];

        $mode = 'production';  // This works!
    }
}

Wither methods and readonly: A practical approach

When working with immutable objects, you often need methods to create modified versions. Enter “wither” methods – unlike traditional setters, these return a new instance with your changes while leaving the original untouched. The PSR-7 HTTP request specification is a prime example of this pattern.

But here's the challenge: readonly properties can't be changed even in wither methods, not even in object copies. While PHP 8.3 lets you modify readonly properties in __clone(), that alone isn't enough since you can't access the new value during cloning. Here's a neat solution to this puzzle:

class Request
{
    private array $changes = [];

    public function __construct(
        public readonly string $method = 'GET',
        public readonly array $headers = [],
    ) {}

    public function withMethod(string $method): self
    {
        $this->changes['method'] = $method;
        $dolly = clone $this;
        $this->changes = [];
        return $dolly;
    }

    public function __clone()
    {
        foreach ($this->changes as $property => $value) {
            $this->$property = $value;
        }
        $this->changes = [];
    }
}

$request = new Request('GET');
$newRequest = $request->withMethod('POST');  // Original $request keeps its GET method

Testing made easier with BypassFinals

Testing code with readonly properties can be tricky – like their cousin final, they can make mocking and testing more complex. Enter BypassFinals library, a clever library that solves this headache.

This handy tool can temporarily remove final and readonly keywords at runtime, making your previously unmockable code testable. Using it is straightforward:

// bootstrap.php or start of your test file
DG\BypassFinals::enable();

// Want to keep readonly but remove final? No problem:
DG\BypassFinals::enable(bypassReadOnly: false);

Key takeaways

Readonly properties are a powerful addition to your PHP toolbox, helping you write more reliable code. Here are the essential points to remember:

  • You can initialize readonly properties any time, but you only get one shot
  • Type declarations are non-negotiable
  • Pre-PHP 8.4, only the defining class could initialize properties
  • Readonly doesn't mean completely immutable – especially for nested objects
  • PHP 8.2 lets you declare entire classes as readonly
  • Testing is made easier with BypassFinals
  • PHP 8.4's public(set) modifier adds welcome flexibility

about a month ago in section PHP | blog written by David Grudl | back to top

You might be interested in

Leave a comment

Text of the comment
Contact

(kvůli gravataru)



*kurzíva* **tučné** "odkaz":http://example.com /--php phpkod(); \--

phpFashion © 2004, 2024 David Grudl | o blogu

Ukázky zdrojových kódů smíte používat s uvedením autora a URL tohoto webu bez dalších omezení.