|
| 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