Skip to content

Boot-time Frozen Services via #[Immutable]#203

Merged
techmahedy merged 12 commits intodoppar:3.xfrom
techmahedy:techmahedy-3.x
Mar 1, 2026
Merged

Boot-time Frozen Services via #[Immutable]#203
techmahedy merged 12 commits intodoppar:3.xfrom
techmahedy:techmahedy-3.x

Conversation

@techmahedy
Copy link
Member

@techmahedy techmahedy commented Mar 1, 2026

Introduces a container-enforced service lifecycle boundary. Services marked #[Immutable] are fully configurable during the boot phase, then permanently frozen for the entire request lifecycle — at the object level, not the container level.

Motivation

The Problem With Mutable Singletons

In every major PHP framework today, singleton services are registered once but remain permanently mutable. Nothing prevents a controller, middleware from silently corrupting shared service state mid-request.

Example:

// Registered as a singleton in AppServiceProvider
$payment = new PaymentService('stripe', 0.08);

// Somewhere in a middleware, 3 files away...
$payment->taxRate = 0.0;    // ← no error. runs silently.
$payment->gateway  = 'paypal'; // ← corrupts every request after this

// Every subsequent inject of PaymentService now gets broken state.
// No exception. No log. No trace. Just wrong behavior.

This is not hypothetical. It is a class of bug that is extremely hard to trace because the mutation and the symptom are separated by request boundaries, middleware layers, and injection chains. The only defence today is code review and convention — neither is enforced at runtime.

What This PR Does

A Clear Lifecycle Boundary

This PR introduces a two-phase service lifecycle enforced at the object level by the container:

Phase Name What Happens Key Behavior
1 Boot Service is created and configured in a ServiceProvider. Properties are writable. Framework sets up gateway, tax rate, credentials from config/env. Properties can be freely modified during setup.
2 Freeze Container calls freeze() after build() completes. Public properties are snapshotted and unset. Magic methods take over all future access. Object becomes immutable; state is locked.
3 Runtime Service is injected into controllers, closures, middleware. All reads work normally. Any write attempt throws ImmutableViolationException immediately. Safe read-only usage; writes are blocked.

What Developers Write

The entire public-facing API is a single attribute and a trait. Nothing else is required from the developer.

namespace App\Services;

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 [
            'total'   => $amount * (1 + $this->taxRate),
            'gateway' => $this->gateway,
        ];
    }
}

Mutable during boot only

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $payment = new PaymentService();
        $payment->gateway  = config('payment.gateway');   // ✓ writable
        $payment->taxRate  = config('payment.tax_rate'); // ✓ writable
        $payment->liveMode = env('APP_ENV') === 'production';

        $this->app->singleton(PaymentService::class, fn() => $payment);
        // container calls freeze() → permanently locked after this point
    }
}

Mutations blocked

class PaymentController
{
    public function __construct(
        private readonly PaymentService $payment
    ) {}

    public function update(Request $request): array
    {
        $this->payment->charge($request->amount);  // method calls work
        echo $this->payment->gateway;             // reads work

        $this->payment->taxRate = 0.0;          // ✗ ImmutableViolationException
    }
}

How This Differs From Existing Solutions

readonly class Doppar #[Immutable]
✅ Properties frozen after construction ✅ Properties frozen after container boot
✅ Engine-level enforcement — fastest possible ✅ Configurable from config()/env() during boot
✅ Works with constructor promotion ✅ Works with ServiceProvider pattern
❌ Cannot configure from config()/env() post-construction ✅ Custom ImmutableViolationException with actionable message
✅ All values must be known at new-time ✅ Clear boot window — mutable during setup, frozen at runtime
❌ Cannot be used with ServiceProvider configuration pattern ✅ Works in controllers, closures, middleware — all paths
❌ No custom exception — throws generic Error ✅ Reads work transparently — zero API changes for consumers
❌ No boot window — immutable from first line ✅ Opt-in per service — no global impact

The critical distinction from readonly class

PHP's readonly freezes on construction. You cannot do this:

readonly class PaymentService
{
    public function __construct(
        public string $gateway,
        public float  $taxRate,
    ) {}
}

// In ServiceProvider — THIS IS IMPOSSIBLE with readonly:
$payment = new PaymentService('', 0);     // must know values at new-time
$payment->gateway = config('payment.gateway'); // Error: readonly property

// With Doppar #[Immutable] — this works perfectly:
$payment = new PaymentService();
$payment->gateway = config('payment.gateway');   // writable during boot
$payment->taxRate = config('payment.tax_rate'); // writable during boot
// container registers singleton → freeze() called → locked forever

The exception message

Phaseolies\DI\Exceptions\ImmutableViolationException
Cannot mutate property $taxRate on immutable service [App\Services\PaymentService].
Services marked #[Immutable] are frozen after instantiatio

PHP offers no runtime method injection, no monkey patching, and no transparent proxy mechanism without extensions (e.g. ocramius/proxy-manager generates separate class files at build time). The freeze() + unset() approach works with PHP's semantics rather than against them.

@techmahedy techmahedy merged commit 509e3de into doppar:3.x Mar 1, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant