Immutability — objects whose state cannot change after construction
Concept
Immutability — an object whose state cannot change after it is constructed. Once created, it's fixed. Any "modification" creates a NEW object.
Mutable: Can be changed after creation. Most objects by default. Immutable: Cannot be changed after creation.
Why immutability matters:
- Thread safety: Multiple threads/requests can hold references to the same immutable object — no race conditions, because no one can change it.
- Predictability: Passing an immutable to a function — it can't surprise you by modifying it.
- Cache safety: Can safely cache immutable objects — they'll never change.
- Value semantics: Immutable objects behave like values, not references. Two
Money(100, 'USD')objects are interchangeable.
PHP 8.1 readonly properties: A property marked readonly can only be set ONCE (in the constructor). Subsequent writes throw an error. readonly class (PHP 8.2+) makes ALL properties readonly automatically.
Implementing immutability before PHP 8.1: Private properties with only getters. The constructor sets all values. Methods that "change" state return new instances.
clone + modify pattern: For immutable classes with many properties, a method creates a clone and modifies just one property in the clone before returning it. PHP 8.1 with() cloning syntax is proposed but not yet merged — use manual approaches.
PSR-7 (HTTP Messages): The standard for HTTP request/response in PHP requires immutability. $request->withAttribute('user', $user) returns a NEW request with the attribute added.
Value Objects in DDD are always immutable: Money, Email, DateRange. If you need a different value, create a new instance.
Code Example
<?php
// PHP 8.1 readonly properties
final class Money
{
public function __construct(
public readonly int $amount, // can only be set in __construct
public readonly string $currency,
) {}
// "Modification" returns a new instance
public function add(Money $other): self
{
if ($this->currency !== $other->currency) throw new \InvalidArgumentException('Currency mismatch');
return new self($this->amount + $other->amount, $this->currency);
}
public function multiply(float $factor): self
{
return new self((int) round($this->amount * $factor), $this->currency);
}
}
$price = new Money(1000, 'USD'); // $10.00
$tax = $price->multiply(0.1); // $1.00 — NEW instance
$total = $price->add($tax); // $11.00 — NEW instance
// $price is still $10.00 — unchanged!
// $price->amount = 999; // Fatal error: Cannot modify readonly property
// PHP 8.2 readonly class — ALL properties are readonly
readonly class Point
{
public function __construct(
public float $x,
public float $y,
) {}
public function translate(float $dx, float $dy): self
{
return new self($this->x + $dx, $this->y + $dy);
}
}
// Clone + modify pattern (pre-8.1 or for complex objects)
final class Config
{
private function __construct(
private readonly string $host,
private readonly int $port,
private readonly bool $ssl,
) {}
public static function create(string $host, int $port, bool $ssl = false): self
{
return new self($host, $port, $ssl);
}
public function withSsl(bool $enabled = true): self
{
$clone = clone $this;
$clone->ssl = $enabled; // modify clone's private property (within same class)
return $clone;
}
public function withPort(int $port): self
{
$clone = clone $this;
$clone->port = $port;
return $clone;
}
}
$config = Config::create('localhost', 3306);
$sslConfig = $config->withSsl(); // new instance — $config unchanged
$altPort = $config->withPort(5432); // another new instance
// PSR-7 immutable request
$request = new \Illuminate\Http\Request();
$newReq = $request->withAttribute('user', $authenticatedUser); // new instance
// $request is unchanged — the original request