We just shipped something we have not seen in any PHP framework before.
Starting today, Doppar services can be marked #[Immutable]. When they are, the framework configures them freely during application boot — from your config files, environment variables, databases, whatever you need — and then permanently freezes them before the first request is handled. After that point, any attempt to mutate a property on that service throws an exception immediately, no matter where in the application the mutation is attempted.
We call this Frozen Services.
The Bug Every PHP Developer Has Shipped
Here is a scenario you have probably encountered, or will encounter.
You register a PaymentService as a singleton. It stores the payment gateway, the tax rate, whether you are in live mode. Standard setup. The service is shared across the request lifecycle — that is the point of singletons.
Three months later, a junior developer writes a middleware. They need to temporarily switch the gateway for a specific route. They find the service in the container, assign the property, and move on. The code review misses it. The tests pass — they do not cover this path.
// Somewhere in CheckoutMiddleware.php, 4 files from anywhere relevant
$payment = app(PaymentService::class);
$payment->gateway = 'sandbox'; // just for testing, I'll remove this later
They do not remove it. Now every request that passes through that middleware — in production, with real customers — uses the sandbox gateway. No exception. No log entry. No stack trace pointing to CheckoutMiddleware.php. Just wrong behaviour that is almost impossible to trace back to a property assignment three files away.
This is not a contrived example. This is a real, common, catastrophic class of bug that every major PHP framework silently allows today.
What Every Framework Gets Wrong
Let us be direct about the state of the ecosystem.
Laravel — singleton services are mutable objects forever. The container has no concept of a service lifecycle beyond "resolved" or "not resolved". Nothing in the framework prevents mutation of shared service state at any point during the request.
Symfony — the compiled container can be frozen, but that freezes the container's bindings and metadata, not the service objects themselves. The actual PaymentService instance sitting inside the frozen Symfony container is a plain mutable PHP object. You can write to it freely.
Slim, Laminas, Yii, CodeIgniter, CakePHP — none of these frameworks have a runtime immutability mechanism for services. The concept does not exist in any of them.
The ecosystem's answer to this problem has always been: code review and convention. Do not mutate singletons. Put it in the team wiki. Add it to the style guide.
We think frameworks should enforce architecture, not document it.
PHP 8.2 Has readonly — Is That Not Enough?
This is the first question we get when we describe Frozen Services. It is a fair question and deserves a direct answer.
PHP 8.2's readonly class freezes properties immediately on construction. There is no window between new and frozen — the moment the object exists, it is immutable.
That sounds ideal, but it creates a problem that is fundamental to how PHP applications are structured.
Your services are not configured at new time. They are configured from config(), from env(), from values that only exist after the framework boots. The standard ServiceProvider pattern — the backbone of Laravel, Doppar, and most modern PHP frameworks — requires that you configure services after constructing them.
// This is how every framework works — and readonly breaks it
readonly class PaymentService
{
public string $gateway;
public float $taxRate;
}
// In your ServiceProvider:
$payment = new PaymentService();
$payment->gateway = config('payment.gateway'); // Fatal error: readonly property
$payment->taxRate = config('payment.tax_rate'); // Fatal error: readonly property
You cannot use readonly with a ServiceProvider configuration pattern. You are forced to either pass all values through the constructor (which means knowing them at new time, which is often impossible) or abandon readonly entirely.
Doppar's Frozen Services solves exactly this gap. The service is mutable during boot — your providers configure it freely — and then frozen before any request code runs.
// Doppar's approach — configure then lock
use Phaseolies\DI\Attributes\Immutable;
use Phaseolies\DI\Concerns\EnforcesImmutability;
#[Immutable]
class PaymentService
{
use EnforcesImmutability;
public string $gateway = 'stripe';
public float $taxRate = 0.08;
public bool $liveMode = false;
}
// In your ServiceProvider:
$payment = new PaymentService();
$payment->gateway = config('payment.gateway'); // ✓ writable during boot
$payment->taxRate = config('payment.tax_rate'); // ✓ writable during boot
$payment->liveMode = env('APP_ENV') === 'production'; // ✓ writable during boot
$this->app->singleton(PaymentService::class, fn() => $payment);
// freeze() called → permanently locked for the entire request lifecycle
This is the boot window. Configure freely. Then the framework locks it. readonly cannot do this. No other framework does this.
The Developer Experience
We worked hard to make this as close to zero boilerplate as possible.
What You Write on the Service
use Phaseolies\DI\Attributes\Immutable;
use Phaseolies\DI\Concerns\EnforcesImmutability;
#[Immutable]
class PaymentService
{
use EnforcesImmutability;
public string $gateway = 'stripe';
public float $taxRate = 0.08;
public bool $liveMode = false;
public function charge(float $amount): array
{
return [
'gateway' => $this->gateway,
'total' => round($amount * (1 + $this->taxRate), 2),
'status' => 'charged',
];
}
}
Two things added from a normal service class: the #[Immutable] attribute and use EnforcesImmutability. Properties stay public. No visibility changes. No constructor boilerplate. No interface to implement.
What You Write in a Controller
class PaymentController extends Controller
{
public function __construct(
private readonly PaymentService $payment
) {}
public function process(Request $request): array
{
return $this->payment->charge($request->amount); // Works exactly as before
}
}
Nothing changes for controllers or any other consumer. Inject it exactly as you did before. Read properties exactly as you did before. The frozen service is indistinguishable from a regular service except that writing to it throws.
What Mutation Looks Like Now
public function charge(Request $request, PaymentService $payment): array
{
try {
$payment->taxRate = 0.0; // Attempted mutation
} catch (ImmutableViolationException $e) {
return [
'success' => false,
'error' => $e->getMessage(),
'currentRate' => $payment->taxRate, // Still 0.08 — unchanged
];
}
return ['success' => true];
}
Response:
{
"success": false,
"error": "Cannot mutate property $taxRate on immutable service [App\\Services\\PaymentService]. Services marked #[Immutable] are frozen after instantiation.",
"currentRate": 0.08
}
The exception is typed. The message names the class and the property. The value is unchanged. The bug that used to succeed silently now fails loudly, immediately, with a stack trace.
The Lifecycle in Full
┌─────────────────────────────────────────────────────────┐
│ BOOT PHASE │
│ │
│ $payment = new PaymentService(); │
│ $payment->gateway = config('payment.gateway'); ✓ │
│ $payment->taxRate = config('payment.tax_rate'); ✓ │
│ $payment->liveMode = env('APP_ENV') === 'prod'; ✓ │
│ │
│ $this->app->singleton(PaymentService::class, ...); │
│ │ │
│ ▼ │
│ Container calls freeze() │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ RUNTIME PHASE │
│ │
│ $payment->gateway // ✓ 'stripe' │
│ $payment->taxRate // ✓ 0.08 │
│ $payment->charge(100.00) // ✓ method calls work │
│ │
│ $payment->gateway = 'pp' // ✗ ImmutableViolation │
│ $payment->taxRate = 0.0 // ✗ ImmutableViolation │
│ unset($payment->gateway) // ✗ ImmutableViolation │
│ clone $payment // ✗ ImmutableViolation │
└─────────────────────────────────────────────────────────┘
Side-by-Side Comparison
| Mutable Singleton | readonly class |
Doppar #[Immutable] |
|
|---|---|---|---|
Configurable from config() / env()
|
✓ | ✗ | ✓ |
| Protected from runtime mutation | ✗ | ✓ | ✓ |
| Works with ServiceProvider pattern | ✓ | ✗ | ✓ |
| Clear typed exception on violation | ✗ | ✗ (generic Error) |
✓ |
| Boot window | — | ✗ | ✓ |
| Reads work transparently | ✓ | ✓ | ✓ |
| Method calls work transparently | ✓ | ✓ | ✓ |
| Framework enforced | ✗ | Engine enforced | Container enforced |
| Works with injection (type hints) | ✓ | ✓ | ✓ |
The Bigger Picture
Frozen Services is not just a convenience feature. It represents a different way of thinking about application architecture in PHP.
Most frameworks treat services as objects — they can be created, configured, injected, and modified at any time by any code with a reference to them. The developer is responsible for ensuring that shared services are not inadvertently mutated. The framework has no opinion.
Doppar now has an opinion.
Services that are configured and shared should be immutable after configuration. The boot phase is for setup. The runtime phase is for use. This boundary should be enforced by the framework — not by a comment in a style guide, not by code review, not by hoping that no one makes a mistake.
This is the same principle that makes readonly properties and immutable value objects valuable in domain-driven design — applied at the service layer, where the risk of mutation is highest and the consequences of a silent mutation are most severe.
We believe this is how PHP application services should work. We believe other frameworks will implement something similar. And we are shipping it today in Doppar.
Learn More
Full documentation Frozen Services
Top comments (3)
It is possible to add parameters to the constructor in Symfony and Laravel when they are injected from the container, so you can use
readonlyclasses.The fact that
readonlyis a PHP solution makes it better than a library/framework solution.The bug example is more a team error than a framework error. If you have classes that use config to create an instance of the service, why would someone add a hard coded value?
If a reviewer didn't catch that it was a bad review.
Here boot window is the only important part. You are right, most of the cases
This feature is available in spring boot