DEV Community

Cover image for Doppar Introduces Frozen Services, Missing in Laravel, Symfony?
Francisco Navarro
Francisco Navarro

Posted on

Doppar Introduces Frozen Services, Missing in Laravel, Symfony?

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

Response:

{
    "success": false,
    "error": "Cannot mutate property $taxRate on immutable service [App\\Services\\PaymentService]. Services marked #[Immutable] are frozen after instantiation.",
    "currentRate": 0.08
}
Enter fullscreen mode Exit fullscreen mode

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      │
└─────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
xwero profile image
david duymelinck

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.

It is possible to add parameters to the constructor in Symfony and Laravel when they are injected from the container, so you can use readonly classes.

The fact that readonly is 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.

Collapse
 
mahedi1501 profile image
Francisco Navarro

Here boot window is the only important part. You are right, most of the cases

Collapse
 
nera_john_78a15b53cb2a profile image
Nera John

This feature is available in spring boot