Na navigaci | Klávesové zkratky

The Hidden Surprises of PHP Readonly Properties

PHP 8.1 brings a powerful tool that significantly improves how we can ensure data integrity – readonly variables. Set once, valid forever. Let's look at how they can make your life easier and what to watch out for when using them.

Let's start with a simple example that illustrates basic usage:

class User
{
	public readonly string $name;

	public function setName(string $name): void
	{
		$this->name = $name; // allowed initialization
	}
}

$user = new User;
$user->setName('John'); // first call - OK
echo $user->name;       // prints "John"
$user->setName('John'); // second call - ERROR: attempt to write second time throws exception
                        // Error: Cannot modify readonly property User::$name

As you can see, once a readonly variable is set, its value cannot be changed.

Initialization and “uninitialized” state

One common misunderstanding is the idea that readonly variables must be set only in the constructor. In reality, they can be initialized at any time during the object's lifecycle – but only once. Before the first assignment, they exist in a special ‘uninitialized’ state. Logically, they also cannot have a default value – that would contradict the principle of one-time initialization.

Requires Types

Readonly variables require explicit data type definition. This is because the ‘uninitialized’ state they use exists only for typed variables. Therefore, a readonly variable cannot be defined without specifying a type. If you don't know the type, use mixed.

Readonly Classes

PHP 8.2 brought the ability to elevate the readonly concept to a higher level – we can mark an entire class as readonly. This automatically makes all its instance properties readonly:

readonly class User
{
	public string $name;  // automatically readonly
	public string $email; // automatically readonly
}

Readonly classes have several important limitations:

  • cannot contain untyped properties
  • cannot contain static properties
  • cannot use dynamic properties (even with the #[AllowDynamicProperties] attribute)
  • in PHP 8.2, only a readonly class could inherit from a readonly class, which is no longer true in PHP 8.3

Scope and Initialization Restrictions

The behavior when attempting to initialize a readonly variable from different contexts is interesting. The following code surprisingly throws an exception, even though it's the first assignment:

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

Similarly, it's not possible to initialize a readonly variable from a child class:

class Employee extends User
{
	public function setName(string $name): void
	{
		$this->name = 'EMP: ' . $name;
		// throws exception: Cannot initialize readonly property User::$name from scope Employee
	}
}

$employee = new Employee;
$employee->setName('John Smith');

Therefore, a readonly variable can only be initialized from the class that defined it. Or more precisely, that was the case. PHP 8.4 brings two important changes:

  • Readonly variables can be initialized from child classes, i.e., from the Employee class
  • Using the public(set) modifier, you can allow writing to readonly variables even outside the class scope:
class User
{
	public(set) readonly string $name;
}

$user = new User;
$user->name = 'John'; // OK
echo $user->name; // OK

Readonly vs. True Data Immutability

When working with readonly variables, it's crucial to understand that readonly alone doesn't guarantee complete data immutability. If we store an object in a readonly variable, its internal state remains modifiable, the object doesn't automatically become immutable:

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 is allowed, even though $settings is readonly!

With arrays, the situation is specific. Direct modification of array elements is not possible because PHP considers it a change of the entire array. However, there's an exception – if the array contains references, we can change their content because PHP doesn't consider this a change to the array itself. This behavior is consistent with PHP's normal functioning:

class Configuration
{
	public readonly array $settings;

	public function initialize(): void
	{
		$dynamicValue = 'development';
		$this->settings = ['mode' => 'debug', 'environment' => &$dynamicValue];
		var_dump($this->settings); // ['mode' => 'debug', 'environment' => 'development']
		$dynamicValue = 'production'; // allowed change
		var_dump($this->settings); // ['mode' => 'debug', 'environment' => 'production']
	}
}

Conversely, direct modification of array elements is not possible:

class Configuration
{
	public readonly array $settings;

	public function initialize(): void
	{
		$this->settings = ['debug' => true, 'cache' => false];
		$this->settings['cache'] = true; // throws exception!
	}
}

Problem with “wither” Methods

When working with immutable objects (whether completely or partially), we often need to implement methods for state changes. These methods, traditionally prefixed with with (as opposed to get), don't modify the original object but return a new instance (clone) of the object with the desired modification. This pattern is used, for example, in the PSR-7 specification for handling HTTP requests.

It would make sense to mark such objects or their properties as readonly. However, we encounter a technical limitation – a readonly property cannot be changed even in a wither method, not even in an object copy. While PHP 8.3 brought the ability to change readonly properties in the __clone() method, this alone isn't enough because we don't have access to the new value intended for change. However, we can solve this using the following workaround:

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 remains with GET

Testing and BypassFinals

When writing tests, we might encounter a problem where readonly (like final) complicates mocking and testing. Fortunately, there's an elegant solution in the form of the BypassFinals library.

This library can remove final and readonly keywords from your code at runtime, allowing you to mock classes and methods that are marked as such. Integration with PHPUnit and other testing frameworks is easy:

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

// If we want to preserve readonly and remove only final:
DG\BypassFinals::enable(bypassReadOnly: false);

Summary

Readonly variables represent a powerful tool for improving the safety and predictability of your code. Remember the key points:

  • Readonly variables can be initialized anytime, but only once
  • They require explicit data type definition
  • Initialization is possible only in the scope of the class that defines them (until PHP 8.4)
  • Readonly doesn't automatically mean immutable – especially for objects
  • With PHP 8.2, we can mark entire classes as readonly
  • For testing, BypassFinals can be used
  • PHP 8.4 brings more flexibility thanks to the public(set) modifier

yesterday 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í.