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