Skip to content

Commit f501250

Browse files
authored
Fix #81: Add article on immutability (#241)
* Fix #81: Add article on immutability * Fix default examples
1 parent 8116a1e commit f501250

File tree

2 files changed

+152
-0
lines changed

2 files changed

+152
-0
lines changed

guide/en/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Key concepts +
4242
* [Configuration](concept/configuration.md) +
4343
* [Aliases](concept/aliases.md) +
4444
* [Events](concept/events.md) +
45+
* [Immutability](concept/immutability.md) +
4546

4647
Handling requests +
4748
-----------------

guide/en/concept/immutability.md

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# Immutability
2+
3+
Immutability means an object's state cannot change after it has been created.
4+
Instead of modifying an instance, you create a new instance with the desired changes.
5+
This approach is common for value objects such as Money, IDs, and DTOs. It helps to avoid accidental side effects:
6+
methods cannot silently change shared state, which makes code easier to reason about.
7+
8+
## Mutable pitfalls (what we avoid)
9+
10+
```php
11+
// A shared base query built once and reused:
12+
$base = Post::find()->where(['status' => Post::STATUS_PUBLISHED]);
13+
14+
// Somewhere deep in the code we only need one post:
15+
$one = $base->limit(1)->one(); // mutates the underlying builder (sticky limit!)
16+
17+
// Later we reuse the same $base expecting a full list:
18+
$list = $base->orderBy(['created_at' => SORT_DESC])->all();
19+
// Oops: still limited to 1 because the previous limit(1) modified $base.
20+
```
21+
22+
## Creating an immutable object in PHP
23+
24+
There is no direct way to modify an instance, but you can use clone to create a new instance with the desired changes.
25+
That is what `with*` methods do.
26+
27+
```php
28+
final class Money
29+
{
30+
public function __construct(
31+
private int $amount,
32+
private string $currency,
33+
) {
34+
$this->validateAmount($amount);
35+
$this->validateCurrency($currency);
36+
}
37+
38+
private function validateAmount(string $amount)
39+
{
40+
if ($amount < 0) {
41+
throw new InvalidArgumentException('Amount must be positive.');
42+
}
43+
}
44+
45+
private function validateCurrency(string $currency)
46+
{
47+
if (!in_array($currency, ['USD', 'EUR'])) {
48+
throw new InvalidArgumentException('Invalid currency. Only USD and EUR are supported.');
49+
}
50+
}
51+
52+
public function withAmount(int $amount): self
53+
{
54+
$this->validateAmount($amount);
55+
56+
if ($amount === $this->amount) {
57+
return $this;
58+
}
59+
60+
$clone = clone $this;
61+
$clone->amount = $amount;
62+
return $clone;
63+
}
64+
65+
public function withCurrency(string $currency): self
66+
{
67+
$this->validateCurrency($currency);
68+
69+
if ($currency === $this->currency) {
70+
return $this;
71+
}
72+
73+
$clone = clone $this;
74+
$clone->currency = $currency;
75+
return $clone;
76+
}
77+
78+
public function amount(): int
79+
{
80+
return $this->amount;
81+
}
82+
83+
public function currency(): string
84+
{
85+
return $this->currency;
86+
}
87+
88+
public function add(self $money): self
89+
{
90+
if ($money->currency !== $this->currency) {
91+
throw new InvalidArgumentException('Currency mismatch. Cannot add money of different currency.');
92+
}
93+
return $this->withAmount($this->amount + $money->amount);
94+
}
95+
}
96+
97+
$price = new Money(1000, 'USD');
98+
$discounted = $price->withAmount(800);
99+
// $price is still 1000 USD, $discounted is 800 USD
100+
```
101+
102+
- We mark the class `final` to prevent subclass mutations; alternatively, design for extension carefully.
103+
- Validate in the constructor and `with*` methods so every instance is always valid.
104+
105+
> [!TIP]
106+
> If you define a simple DTO, you can use modern PHP `readonly` and leave properties `public`. The `readonly` keyword
107+
> would ensure that the properties cannot be modified after the object is created.
108+
109+
## Using clone (and why it is cheap)
110+
111+
PHP's clone performs a shallow copy of the object. For immutable value objects that contain only scalars
112+
or other immutable objects, shallow cloning is enough and fast. In modern PHP, cloning small value objects is
113+
inexpensive in both time and memory.
114+
115+
If your object holds mutable sub-objects that must also be copied, implement `__clone` to deep-clone them:
116+
117+
```php
118+
final class Order
119+
{
120+
public function __construct(
121+
private Money $total
122+
) {}
123+
124+
public function total(): Money
125+
{
126+
return $this->total;
127+
}
128+
129+
public function __clone(): void
130+
{
131+
// Money is immutable in our example, so a deep clone is not required.
132+
// If it were mutable, you could do: $this->total = clone $this->total;
133+
}
134+
135+
public function withTotal(Money $total): self
136+
{
137+
$clone = clone $this;
138+
$clone->total = $total;
139+
return $clone;
140+
}
141+
}
142+
```
143+
144+
## Usage style
145+
146+
- Build a value object once and pass it around. If you need a change, use a `with*` method that returns a new instance.
147+
- Prefer scalar/immutable fields inside immutable objects; if a field can mutate, isolate it and deep-clone in `__clone`
148+
when needed.
149+
150+
Immutability aligns well with Yii's preference for predictable, side-effect-free code and makes services, caching,
151+
and configuration more robust.

0 commit comments

Comments
 (0)