Advanced Dependency Injection in PHP 8.4: From Containers to Attributes

Dependency Injection in PHP 8.4: Autowiring Limits, Lazy Objects, and Compiled Containers

Most PHP developers learn dependency injection as “type-hint an interface in the constructor and let the framework work it out”. That covers perhaps eighty percent of real cases. The remaining twenty percent — scalar configuration, multiple implementations of one interface, services too expensive to build on every request, and dependency cycles — is where teams end up reaching for the service locator and quietly undoing the benefit.

PHP 8.4 changes one part of this materially. Lazy objects are now a language feature rather than something you bolt on with a proxy library, which removes the most common excuse for injecting the container itself.

Injection and Containers Are Different Things

Dependency injection is a design principle: an object receives its collaborators instead of constructing or locating them. A container is a tool that automates the wiring. You can practise the first with no container at all, and you can use a container while thoroughly violating the first.

<?php

declare(strict_types=1);

// Not dependency injection - the class reaches out and takes what it wants.
final class OrderProcessor
{
    public function process(Order $order): void
    {
        $gateway = Container::getInstance()->get(PaymentGateway::class);
        $mailer  = Container::getInstance()->get(Mailer::class);
        // Dependencies are invisible from outside. Testing requires a
        // configured global container. The signature is a lie.
    }
}

// Dependency injection - collaborators are declared and supplied.
final class OrderProcessor
{
    public function __construct(
        private readonly PaymentGateway $gateway,
        private readonly Mailer $mailer,
        private readonly LoggerInterface $logger,
    ) {}

    public function process(Order $order): void
    {
        $this->gateway->charge($order->total());
        $this->mailer->send($order->confirmationFor());
    }
}

The practical test is whether you can instantiate the class in a test with new and hand-built fakes. If that requires booting a container, the class is using a service locator regardless of what the framework documentation calls it.

The one place a container legitimately appears in application code is the composition root — the single entry point where the object graph is assembled. Everywhere else, dependencies arrive through the constructor.

Where Autowiring Runs Out

Autowiring resolves a constructor by reading its parameter types with reflection. It works when every parameter is a class or interface with exactly one candidate implementation. Four situations break it, and each has a distinct fix.

SituationWhy reflection failsFix
string $apiKeyNo class to resolveExplicit parameter binding
Two implementations of one interfaceAmbiguous candidateContextual binding
Cache|null $cacheUnion has no single answerBind the concrete type explicitly
Object built from runtime stateValue is not known at wiring timeInject a factory, not the object

Contextual binding is the one worth showing, because the alternative — creating PrimaryDatabaseConnection and ReplicaDatabaseConnection marker interfaces purely to satisfy the container — pollutes the domain with infrastructure concerns.

<?php

// Same interface, different implementation depending on the consumer.
$container->when(ReportGenerator::class)
          ->needs(DatabaseConnection::class)
          ->give(ReplicaConnection::class);

$container->when(OrderProcessor::class)
          ->needs(DatabaseConnection::class)
          ->give(PrimaryConnection::class);

// Scalars need an explicit source. Reading env vars inside the service
// would make it untestable and hide the dependency.
$container->when(StripeGateway::class)
          ->needs('$apiKey')
          ->give(static fn(): string => $_ENV['STRIPE_SECRET_KEY']);

When an object depends on data only known at runtime — a specific tenant, a request-scoped user — inject a factory instead:

<?php

// Wrong: the container cannot know which tenant at wiring time.
final class ReportBuilder
{
    public function __construct(private readonly TenantConnection $connection) {}
}

// Right: the factory closes over its own dependencies; the caller supplies
// the runtime value.
final class TenantConnectionFactory
{
    public function __construct(
        private readonly ConnectionPool $pool,
        private readonly TenantRegistry $registry,
    ) {}

    public function for(string $tenantId): TenantConnection
    {
        $config = $this->registry->lookup($tenantId);
        return $this->pool->acquire($config->dsn(), $config->credentials());
    }
}

final class ReportBuilder
{
    public function __construct(private readonly TenantConnectionFactory $connections) {}

    public function build(string $tenantId, DateRange $range): Report
    {
        $connection = $this->connections->for($tenantId);
        // ...
    }
}

Native Lazy Objects in PHP 8.4

The long-standing argument for injecting a container was cost: a controller with six dependencies pays to construct all six even when a request touches one. Frameworks solved this with generated proxy classes from libraries like ProxyManager, which required code generation and a build step.

PHP 8.4 makes it native. An object can be created in an uninitialised state and populated on first access to any property or method, and the two flavours have meaningfully different semantics.

<?php

declare(strict_types=1);

final class ElasticsearchClient
{
    private Connection $connection;

    public function __construct(
        private readonly string $host,
        private readonly string $apiKey,
    ) {
        // Opens a socket and negotiates TLS on construction - expensive,
        // and wasted on any request that never runs a search.
        $this->connection = Connection::open($this->host, $this->apiKey);
    }

    public function search(string $index, array $query): array
    {
        return $this->connection->request('POST', "/{$index}/_search", $query);
    }
}

$reflector = new ReflectionClass(ElasticsearchClient::class);

// GHOST: the instance IS the real object; it initialises itself in place.
// Identity is preserved, so === comparisons behave normally.
$client = $reflector->newLazyGhost(
    static function (ElasticsearchClient $client): void {
        $client->__construct($_ENV['ES_HOST'], $_ENV['ES_API_KEY']);
    }
);

// Nothing has connected yet. The socket opens on the line below.
$results = $client->search('orders', ['query' => ['match_all' => new stdClass()]]);

A ghost initialises itself, so it suits objects you fully control. A proxy delegates to a separately constructed instance, which is what you need when the real object comes from a factory whose return type you cannot construct in place:

<?php

// PROXY: the factory returns the real object; the proxy forwards to it.
$client = $reflector->newLazyProxy(
    static fn(ElasticsearchClient $proxy): ElasticsearchClient =>
        ClientFactory::fromConfig($config)      // any construction strategy
);

// Some properties should stay readable without triggering initialisation -
// an ID used for logging, for instance.
$idProperty = $reflector->getProperty('host');
$idProperty->setRawValueWithoutLazyInitialization($client, 'es.internal');
// Reading 'host' now returns the raw value; the object stays uninitialised.

The distinction that matters in practice: a ghost keeps object identity, so $ghost === $realInstance holds and instanceof works against final classes. A proxy is a distinct object wrapping another, which historically required the target class to be non-final. The full semantics — including how lazy objects interact with readonly properties, serialisation, and destructors — are documented in the PHP manual’s lazy objects chapter.

Lazy objects interact well with PHP 8.4 property hooks, since a hook that computes a value on read can sit on a class whose construction is itself deferred.

Compiled Containers and Why Reflection Costs Add Up

A runtime container resolves each service by reflecting on its constructor, reading parameter types, and recursing into dependencies — on every request. A compiled container performs that analysis once at build time and emits a PHP file of plain factory methods.

<?php
// Roughly what a compiled container emits - no reflection at runtime.

final class CompiledContainer implements Psr\Container\ContainerInterface
{
    /** @var array<string,object> */
    private array $singletons = [];

    public function get(string $id): mixed
    {
        return match ($id) {
            OrderProcessor::class  => $this->getOrderProcessor(),
            PaymentGateway::class  => $this->getPaymentGateway(),
            default => throw new NotFoundException("Service {$id} is not registered"),
        };
    }

    public function has(string $id): bool
    {
        return in_array($id, [OrderProcessor::class, PaymentGateway::class], true);
    }

    private function getOrderProcessor(): OrderProcessor
    {
        return $this->singletons[OrderProcessor::class] ??= new OrderProcessor(
            $this->getPaymentGateway(),
            $this->getMailer(),
            $this->getLogger(),
        );
    }
}

Two things make this fast. Resolution becomes a direct method call instead of a reflection walk, and the emitted file is a static PHP artifact that opcache holds in shared memory across requests. The compiled container is one of the clearest beneficiaries of a well-tuned opcode cache — the interaction is covered in the PHP opcode cache performance guide.

The tradeoff is that compilation must be part of deployment. A stale compiled container running against new service definitions produces failures that look like impossible bugs. Two rules keep this safe: compile in CI, never lazily on the first production request, and treat the compiled file as a build artifact that is deleted and regenerated rather than patched.

Circular Dependencies Are a Design Signal

When A requires B and B requires A, the container cannot construct either. Setter injection will break the cycle and is almost always the wrong response, because it produces objects that exist in a partially-wired state.

A cycle nearly always means one class has too many responsibilities. The usual resolution is to extract the shared concern:

<?php

// The cycle: UserService needs to notify, NotificationService needs user data.
final class UserService
{
    public function __construct(private readonly NotificationService $notifications) {}
}
final class NotificationService
{
    public function __construct(private readonly UserService $users) {}   // deadlock
}

// The fix: NotificationService never needed the whole service, only a lookup.
interface UserDirectory
{
    public function findEmail(string $userId): ?string;
}

final class UserRepository implements UserDirectory
{
    public function findEmail(string $userId): ?string { /* ... */ }
}

final class NotificationService
{
    public function __construct(private readonly UserDirectory $directory) {}
}

final class UserService
{
    public function __construct(
        private readonly UserRepository $repository,
        private readonly NotificationService $notifications,
    ) {}
}

The graph is now acyclic and both classes state their real requirements. NotificationService depending on a narrow UserDirectory rather than the full UserService is also a more honest contract — it can only do what it actually needs to do.

Where a cycle genuinely cannot be broken — some event dispatcher arrangements qualify — a lazy proxy defers the construction of one side past the point the cycle would close. That is a legitimate use, and it is now a language feature rather than a library dependency.

Interface Standardisation

Containers implement PSR-11, which specifies exactly two methods: get(string $id): mixed and has(string $id): bool. Registration, autowiring, and contextual binding are deliberately outside the standard, so those APIs differ across implementations.

The consequence for library authors is a useful discipline. A package should type-hint ContainerInterface only when it genuinely needs arbitrary lookup — a plugin dispatcher resolving handler classes by name, for example. Anything else should declare its actual dependencies and let the consuming application wire them.

Service Lifetimes and the Shared-State Trap

Containers offer at least two lifetimes: singleton, where one instance is reused, and transient, where every resolution builds a fresh object. Under classic PHP-FPM the distinction is mild, because the process dies at the end of each request and takes all state with it.

Under a long-running worker — RoadRunner, Swoole, FrankenPHP — that safety net is gone. A singleton constructed during request one is still alive during request four thousand, and any state it accumulated is now shared between unrelated users.

<?php

// Harmless under PHP-FPM. A data leak under RoadRunner.
final class AuditLogger
{
    /** @var list<string> */
    private array $entries = [];          // never cleared between requests

    private ?string $currentUserId = null; // request 2 sees request 1's user

    public function forUser(string $userId): void
    {
        $this->currentUserId = $userId;
    }

    public function record(string $action): void
    {
        $this->entries[] = "{$this->currentUserId}: {$action}";
    }
}

Two rules keep this safe. Make singletons stateless — they may hold configuration and other services, never request data. And pass request-scoped values as method arguments rather than storing them on the instance:

<?php

// Stateless singleton: the user arrives with each call, nothing persists.
final class AuditLogger
{
    public function __construct(private readonly LogWriter $writer) {}

    public function record(string $userId, string $action): void
    {
        $this->writer->append(['user' => $userId, 'action' => $action]);
    }
}

Anything genuinely request-scoped — the authenticated user, a correlation ID, an open transaction — needs a scope the container resets between requests, or it should not live in the container at all. Worker-based runtimes make this a correctness requirement rather than a style preference; the runtime characteristics are covered in the event-driven PHP with RoadRunner guide.

Testing Without Booting Anything

The payoff for all of this discipline shows up in the test suite. A properly injected class needs no container, no framework kernel, and no database:

<?php

use PHPUnit\Framework\TestCase;

final class OrderProcessorTest extends TestCase
{
    public function testFailedChargeDoesNotSendConfirmation(): void
    {
        $gateway = $this->createMock(PaymentGateway::class);
        $gateway->method('charge')->willThrowException(new PaymentDeclined());

        $mailer = $this->createMock(Mailer::class);
        $mailer->expects($this->never())->method('send');

        // Plain construction. No kernel boot, no container, no fixtures.
        $processor = new OrderProcessor(
            $gateway,
            $mailer,
            new NullLogger(),
        );

        $this->expectException(PaymentDeclined::class);
        $processor->process(OrderFixture::pending());
    }
}

That test runs in microseconds and fails for exactly one reason. Compare it with the service-locator version, which needs a configured global container before it can assert anything — and which passes or fails based on wiring unrelated to the behaviour under test. Test setup difficulty is a reliable diagnostic: when a unit test needs infrastructure, the class is usually reaching for dependencies rather than receiving them.

What Holds Up in Practice

  • Constructor injection with readonly properties by default. The object is valid the moment it exists and cannot drift afterwards.
  • Reserve the container for the composition root. If ContainerInterface appears in a domain class, that is the bug.
  • Inject factories for runtime-dependent objects, rather than injecting the container and calling get() inside a method.
  • Use lazy ghosts for expensive dependencies, now that it costs no extra library and no code generation.
  • Compile the container in CI and fail the build when definitions and compiled output disagree.
  • Treat a circular dependency as a modelling problem first. Reach for a lazy proxy only after establishing the cycle is genuinely intrinsic.

The measure of a well-wired application is not how clever the container configuration is. It is whether a developer can open any class, read its constructor, and know exactly what it depends on — then instantiate it in a test without booting anything.

editor's pick

latest video

news via inbox

Nulla turp dis cursus. Integer liberos  euismod pretium faucibua

Leave A Comment