Dependency Injection in Modern and Legacy PHP

Dependency Injection in Modern and Legacy PHP

A decade or two on, Tom Butler’s (Tom B Zombie) DICE remains the smallest, quickest, easiest and arguably fastest amongst all small to mid to large projects — reportedly compilation of Symfony, PHP-DI or others finally bests DICE, but projects ‘huge enough’ to matter are uncommon.

Here’s the quick lay of the land on Dice vs. how DI is typically done in modern PHP.

What Dice is (in practice)

  • Ultra-light DI container that relies on reflection autowiring + a small rule system (arrays) to handle special cases.
  • No build/compile step; you just: new Dice(); $dice->addRule(Foo::class, ['shared' => true]); $dice->create(Controller::class);
  • Features you’ll actually use:
  • Autowire by type (constructor injection).
  • Shared (singleton-like) services.
  • Substitutions (bind interface → concrete).
  • Named instances and closures/factories for edge cases.

Dice strengths

  • Tiny footprint & zero ceremony. Great for small apps, scripts, CLI tools, library examples.
  • Fast to get going. Little config, mostly “just works” when types are clear.
  • Framework-agnostic. No lock-in, minimal surface area.

Dice limitations

  • Reflection at runtime only (no compile/cache pipeline). On very large graphs this can add overhead.
  • Less ecosystem/integration. You’ll wire your own bridges.
  • Fewer advanced patterns out of the box (scopes, decorators, lazy proxies, compiled definitions, etc.).

What “current best practice” DI in PHP usually looks like

Not everyone uses the same container, but common traits have converged:

  1. Constructor Injection + Interfaces. Primary pattern. Avoid property/setter injection except for rare optional deps.
  2. PSR-11 containers as the interop boundary (so libraries can accept any compliant container).
  3. Autowiring as the default; explicit definitions for ambiguous/conditional cases.
  4. Composition root: build the container once at the app edge (e.g., public/index.php), never fetch it inside domain code (avoid Service Locator).
  5. Compile/cache when possible for performance (Symfony’s compiled container, PHP-DI’s AOT/compiled definitions).
  6. Factories for conditional/expensive objects; config objects for environment/params.
  7. Testability first: DI enables easy substitution of test doubles without touching the container.

How Dice compares to popular options

CriterionDicePHP-DISymfony DILaravel ContainerLeague/Container / Laminas
AutowiringYes (reflection)Yes (+ definitions, attributes)Yes (autowire/autoconfigure)Yes (auto + bindings)Yes (varies)
PSR-11Usually wrapped via adapterYesYesYesYes
Compile/AOTNoOptional (compiled defs)Yes (very strong)No compile step; optimizedNo/varies
Ecosystem/bridgesMinimalManyVast (Symfony)Vast (Laravel)Moderate
Best fitSmall, simple, quickGeneral-purpose, framework-agnosticLarge apps, perf & toolingLaravel appsLightweight builds

Bottom line:

  • Choose Dice if you want minimalism and are comfortable relying on reflection autowiring + a few rules.
  • Choose PHP-DI if you want a light yet full-featured container with attributes, definitions, and optional compilation that works well outside big frameworks.
  • Use Symfony’s (or Laravel’s) native container if you’re in those ecosystems and want first-class tooling, performance (compiled), and integrations.

Tiny code sketches

Dice

use Dice\Dice;

class Logger {}
interface PaymentGateway {}
class StripeGateway implements PaymentGateway {
    public function __construct(Logger $log) {}
}
class CheckoutController {
    public function __construct(PaymentGateway $gw) {}
}

$dice = new Dice();

// Bind interface → concrete; share Logger
$dice->addRule(Logger::class, ['shared' => true]);
$dice->addRule(PaymentGateway::class, ['substitutions' => [
    PaymentGateway::class => new Dice\Instance(StripeGateway::class)
]]);

$controller = $dice->create(CheckoutController::class);

PHP-DI

use DI\ContainerBuilder;
use function DI\autowire;
use function DI\create;

$builder = new ContainerBuilder();
// Optional: $builder->enableCompilation(__DIR__ . '/var/cache');

$builder->addDefinitions([
    Logger::class => autowire()->lazy(),     // or shared by default
    PaymentGateway::class => autowire(StripeGateway::class),
    CheckoutController::class => autowire(),
]);

$container = $builder->build();
$controller = $container->get(CheckoutController::class);

Symfony (PHP config)

// services.php
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\DependencyInjection\Reference;

return function (ContainerConfigurator $c) {
    $s = $c->services()->defaults()->autowire()->autoconfigure();
    $s->set(Logger::class);
    $s->set(StripeGateway::class);
    $s->alias(PaymentGateway::class, StripeGateway::class);
    $s->set(CheckoutController::class);
};

Practical guidance

  • If you’re starting small or shipping a CLI/daemon: Dice is perfectly fine and super quick. Keep constructors explicit and types clear.
  • If you expect growth, teams, or need caching/attributes/integrations: start with PHP-DI (framework-agnostic) or the native container of your chosen framework.
  • Regardless of container: keep DI out of your domain code; treat the container as an app-edge detail, prefer interfaces, and write small, immutable services.


Short answer: compiled containers are fastest; Dice (pure reflection) is comparatively slower. Here’s a practical snapshot from the best neutral benchmark I know plus vendor docs.

Head-to-head (representative cases)

  • Warm, repeated resolves (100k singletons)
    Zen ≈ 1.00× (baseline), Symfony1.05×, PHP-DI (compiled)1.34×, Dice1.74×. (Máté Kocsis)
  • Warm, single large graph (1,000 objects)
    Zen ≈ 1.00×, Symfony1.05×, PHP-DI (compiled)1.30×, Dice1.87×. (Máté Kocsis)
  • Prototype scope (re-create objects)
    Symfony leads by a wide margin; Dice is much slower; PHP-DI didn’t participate in those prototype tests in this suite. (Máté Kocsis)

Source: Máté Kocsis’s DI container benchmarks (PHP 8, nginx+FPM, multiple suites). It also explains why compiled containers win: no runtime reflection and minimal logic on hot paths. (Máté Kocsis)

What this means in practice

  • If you’re on Symfony, the compiled container is about as fast as it gets for PHP DI. (Symfony)
  • On PHP-DI, enable compilation in prod; v6 added compilation and saw sizable real-world gains (e.g., ~32% on externals.io). (PHP-DI)
  • Dice is tiny and convenient, but its runtime reflection shows up in hot loops compared to compiled containers. (Older 2014 micro-benches put Dice on top, but that article’s methodology is widely considered flawed/outdated.) (SitePoint, Máté Kocsis)

Rules of thumb

  • Speed order (prod, tuned): Symfony ≈ PHP-DI (compiled) ≪ Dice (reflection). Exact deltas depend on graph size/scope, but the pattern holds across suites. (Máté Kocsis)
  • Container overhead is usually tiny if you resolve at the composition root and pass deps through constructors (don’t call the container per request handler repeatedly). Bench author stresses this caveat. (Máté Kocsis)

Real-World Daily Use

There is a setup-block of “addRule()” array definitions that establish a basic grid of rules (primarily defining “shared” / singleton classes). This gets compiled JiT and basically never touched again, because wildcarding is available to automatically handle future classes added. Creation of one single class via “new Dice()” literally allows all other classes to be instantiated in constructors. It’s hard to imagine anything simpler.

Dependency Injection in Modern and Legacy PHP

Leave a Comment