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
Leave a comment