Contents
- 1 PHP Immutability Explained
- 1.1 What is immutability, really?
- 1.2 Why immutability matters (more than you think)
- 1.3 The tools PHP gives you
- 1.4 Creating modified copies (the "with" pattern)
- 1.5 The inheritance challenge
- 1.6 The real-world difference
- 1.7 Constants: the original immutability
- 1.8 What about the edges?
- 1.9 Building immutable systems
- 1.10 The philosophy underneath
PHP Immutability Explained
There's a moment every developer knows well. You pass an object to a function, trusting it won't be changed. Then something breaks. Silently. A property shifted, a value altered, and suddenly your code behaves in ways that make no sense. You find yourself staring at logs at 2 AM wondering how the state got corrupted when you never explicitly modified it.
This is the quiet chaos of mutable objects.
Immutability is one of those concepts that sounds abstract until you actually need it. Then it becomes something close to salvation. It's the difference between passing around a brick that might get picked up and rearranged by anyone who touches it, and passing around a diamond that stays exactly as it was, forever.
Let me walk you through what immutability really means, why it matters more than you might think, and how to actually use it in modern PHP.
What is immutability, really?
At its core, immutability means this: once an object is created, its state cannot change. Not from within. Not from outside. Not ever. The object exists as a fixed point in your application's universe.
In PHP, this is easier to understand if you think about scalars first. When you pass an integer to a function and modify it, the original integer in the calling code remains untouched. Integers are passed by value. They're immutable by nature. You get a copy, you work with the copy, and the original never changes.
Objects, though? They're different. Objects are passed by reference. When you hand an object to a function, you're not giving it a copy—you're giving it directions to the same object. If that function modifies the object, every piece of code holding a reference to it sees those changes. This is the source of countless bugs.
An immutable object flips this script. Once created, it stays exactly as it was. Forever. If you need a modified version, you don't change the original. You create a new object with the new values.
Why immutability matters (more than you think)
Predictability
The biggest win is psychological. You can reason about your code. You can pass an object around and know, with absolute certainty, that it won't mysteriously change. No surprise mutations hiding in some distant method you forgot about. No debugging sessions hunting for where the state got corrupted.
Concurrency without fear
If you work with threading or async code—and increasingly, developers do—immutability is a superpower. Threads can safely read the same object simultaneously without worrying about race conditions or synchronization headaches. The object can't change, so there's nothing to race over.
Value-based identity
Here's something that feels subtle but changes how you think: two immutable objects containing identical data are, for all practical purposes, the same. This is different from how we usually think about object identity. Two mutable objects with the same data are still different objects because they could diverge at any moment. Immutable objects, though? Their identity is based on their value.
Thread safety without locks
When an object is immutable, you don't need locks or synchronization primitives. Multiple threads can read it simultaneously without any coordination. This removes entire categories of bugs and makes concurrent code dramatically simpler.
Making valid objects impossible to invalidate
Here's the real power: you create an immutable object, validate it thoroughly during construction, and then you know—absolutely know—that it will never become invalid. A user object created with valid data cannot later have that data corrupted by some careless code path. This is especially powerful for domain objects and value objects.
The tools PHP gives you
PHP 8.1 introduced the readonly keyword, which is the foundation for immutability in modern PHP. A readonly property can only be set once, during object initialization. After that, it's locked.
<?php
readonly class Address {
public function __construct(
public string $name,
public string $line1,
public string $line2,
public string $city,
public string $state,
public string $zipCode,
public ?string $phone,
) {}
}
$myAddress = new Address(
'John Doe',
'4711 Main St',
'Apt 4',
'Anytown',
'CA',
'90210',
null
);
Once this object exists, nothing can change it. Try to modify $myAddress->city and PHP will throw an error. The object becomes a promise: this address will always be exactly what it was when created.
But here's the thing. Immutable objects create a new problem: how do you handle changes?
Creating modified copies (the "with" pattern)
The conventional answer is simple: you don't modify. You create a new object with the modified values. But doing this naively is tedious. If an object has 11 properties and you want to change just one, you'd need to pass all 11 values to the constructor again. That's error-prone and verbose.
Enter the "with" pattern. It's elegant. You add a method that creates a copy of the object with specified properties changed:
<?php
enum Unchanged {
case Value;
}
readonly class Address {
public function __construct(
public string $name,
public string $line1,
public string $line2,
public string $city,
public string $state,
public string $zipCode,
public ?string $phone,
) {}
public function with(
string|Unchanged $name = Unchanged::Value,
string|Unchanged $line1 = Unchanged::Value,
string|Unchanged $line2 = Unchanged::Value,
string|Unchanged $city = Unchanged::Value,
string|Unchanged $state = Unchanged::Value,
string|Unchanged $zipCode = Unchanged::Value,
string|null|Unchanged $phone = Unchanged::Value,
) {
return new self(
$name !== Unchanged::Value ? $name : $this->name,
$line1 !== Unchanged::Value ? $line1 : $this->line1,
$line2 !== Unchanged::Value ? $line2 : $this->line2,
$city !== Unchanged::Value ? $city : $this->city,
$state !== Unchanged::Value ? $state : $this->state,
$zipCode !== Unchanged::Value ? $zipCode : $this->zipCode,
$phone !== Unchanged::Value ? $phone : $this->phone,
);
}
}
Now you can modify an address cleanly using named arguments:
$updatedAddress = $myAddress->with(city: 'New York');
The original $myAddress remains untouched. The new object, $updatedAddress, exists with the change applied. This is immutability in practice.
The inheritance challenge
Here's where immutability gets interesting: inheritance. If a parent class is immutable, can a child class be mutable? The answer is no, or chaos follows.
When you extend an immutable class, the child must also be immutable. This preserves the guarantee across the entire inheritance chain. A developer receiving an immutable object can trust its immutability, even if it's actually an instance of some subclass they've never seen.
And there's more: if an immutable property contains an object, that object must also be immutable. Otherwise, you could modify the nested object and violate the parent's immutability guarantee.
This creates a beautiful constraint: immutability propagates. It encourages clean design where objects are simple, focused, and trustworthy.
The real-world difference
Let me make this concrete. Imagine you're building an e-commerce system. You have a Price object that holds a currency and an amount. Early in development, you create it as a mutable object. Reasonable enough at the time.
Later, someone writes code that passes prices around, modifying them on the fly. The price gets adjusted for discounts, for taxes, for promotional codes. Each modification happens in different parts of the codebase. The object transforms as it moves through the system.
Then a bug appears: sometimes prices are negative. Sometimes they're wildly incorrect. You trace through the code and find that modifications happened in an unexpected order, or a discount was applied twice, or someone modified a price object that was supposed to be immutable "by convention" but nothing actually enforced that.
Now imagine the same system with immutable objects. A Price is created with specific values and cannot change. When you need a discounted price, you create a new Price object representing the discounted value. Each operation is explicit. Each step creates a new, validated object. When a price is wrong, the bug is obvious: you created it with wrong values. The path to the error is traceable. There's no hidden mutation somewhere deep in a method you didn't know existed.
The immutable version isn't just safer. It's more predictable. It's easier to debug. It's easier to test because you don't have to worry about hidden state changes affecting your assertions.
Constants: the original immutability
Before readonly, PHP had constants. Constants defined with const or define() are immutable—they cannot be changed at runtime.
const TIMEZONE = 'UTC';
Once defined, TIMEZONE is fixed. PHP will throw an error if you try to redefine it. Constants work well for simple scalar values. But as soon as you need structured data—objects, complex configurations, domain objects—constants fall short. You need objects. And you need those objects to be immutable, not just by convention, but by language enforcement.
That's what readonly gives you.
What about the edges?
Immutability isn't always straightforward. There are legitimate questions about performance. Creating new objects instead of modifying existing ones uses more memory. In hot loops or systems processing millions of objects per second, this can matter.
The honest answer: it depends. Modern PHP is fast enough that the overhead is often negligible compared to the benefits. And in many cases, the cleaner code structure and reduced debugging time more than compensates for the slightly higher memory usage.
There's also the question of ecosystem support. Not every library in PHP uses immutable objects. You'll sometimes need to work with mutable objects created by external code. The solution is pragmatic: use immutability where it adds value, especially in domain objects and value objects. Use mutability where the trade-off doesn't justify the overhead.
Building immutable systems
Start thinking about which objects in your codebase deserve immutability. Value objects—objects that represent quantities or values without identity—are perfect candidates. A Money object. A DateTime object (though PHP's built-in one is mutable, unfortunately). A Color object. An Email object. Objects that exist to represent a specific value and nothing else.
Domain objects—objects that represent entities in your business logic—often benefit too. Once created and validated, they should probably be immutable. This forces you to think carefully about state transitions. Instead of modifying an object, you think about commands or events that produce new states.
Data Transfer Objects (DTOs) are natural fits for immutability. They carry data from one place to another. They shouldn't change mid-journey.
Use the readonly keyword and the with pattern. Make it habitual. When someone hands you an object, they're handing you a promise that it won't change. That promise, kept consistently, makes PHP code feel safer, clearer, and more trustworthy.
The philosophy underneath
Immutability is really about reducing surprise. In a codebase with mutable objects, you have to trace through method calls and worry about side effects. You have to mentally model how state changes as code executes. You have to hold entire graphs of object relationships in your head.
Immutable code is simpler because the graph is static. The structure doesn't shift underneath you. Functions become more like pure functions in functional programming—given the same input, they always produce the same output. No hidden state changes. No surprises at midnight.
This is why immutability, for all its abstractness, feels real when you work with it. It's about building systems that don't trip you up. It's about writing code that's easier to understand, easier to test, easier to debug. It's about moving the enforcer of invariants from your brain (and your coffee supply) to the language itself.
When you build with immutability, you're not just using a language feature. You're making a choice about what kind of code you want to write. Code that's predictable. Code that's trustworthy. Code that tells a story instead of hiding one.
The best immutable code doesn't feel restrictive. It feels like freedom. Freedom from tracking mutations. Freedom from surprise side effects. Freedom to pass objects around knowing they'll be exactly as they were when you created them.
In PHP, that freedom is now a keystroke away.