level-2/dice
Dice is a lightweight, dependency-free dependency injection container for PHP. It provides automatic constructor and setter injection, factory callbacks, shared instances, and simple configuration for decoupled, testable applications.
Install and first call. composer require level-2/dice — no service provider, no config file, no cache directory. The entire entry point is create():
$dice = new \Dice\Dice;
$report = $dice->create(App\Reports\ReportGenerator::class);
That already works for any class whose constructor dependencies are concrete, type-hinted classes — Dice reflects the constructor and recurses. So the first thing to try is deleting a hand-written new Foo(new Bar(new Baz)) from a bootstrap file or CLI script and replacing it with create(). That single change tells you immediately whether Dice earns its place: if the graph is all concrete classes, you get zero-config auto-wiring; the moment you hit an interface or a scalar argument, you need rules.
Where to look first. Read the rules array schema, not the source. Everything Dice does is expressed as a plain array keyed by class name — substitutions, shared, constructParams, call, instanceOf, shareInstances. Once you internalise that a "rule" is just config data, the package stops feeling like magic. Note that rules are plain arrays, so they can live in config/dice.php returning require-able PHP, or even be decoded from JSON/YAML.
First real use case. The canonical one: an interface in a constructor plus a single substitution rule.
$dice->addRules([
App\Reports\ReportGenerator::class => [
'substitutions' => [
App\Contracts\Renderer::class => ['instance', new App\Render\PdfRenderer],
],
],
]);
$report = $dice->create(App\Reports\ReportGenerator::class); // works
Look at the merged rule with $dice->getRule(ReportGenerator::class) whenever something behaves unexpectedly — it shows you what Dice will actually use.
One rules file loaded at bootstrap. Keep a single dice.config.php at the application root and load it once:
$dice = new \Dice\Dice;
$dice->addRules(require __DIR__ . '/dice.config.php');
Treat it like a routing table: one class per entry, only the entries that deviate from auto-wiring. Resist writing a rule for every class — the file's value is that it's short enough to read in one screen.
Interface → implementation mapping is the 80% case. ['instance', $obj] injects a literal object; use it for implementations that are stateless or cheap. For anything stateful, combine with 'shared' => true so the same object is reused across the graph.
shared is per-container, not global. This is the pattern that bites: shared makes Dice return the same instance for a rule for the lifetime of that Dice instance. Build one container at bootstrap, pass it around, and you get singleton behaviour. Construct a second Dice in a test and you get a fresh world — which is actually what you want for tests.
Setter injection via call. For legacy classes that don't take everything in the constructor:
'call' => [
['setLogger', ['instance', $logger]],
],
Calls run after construction, in order. Use them sparingly; a long call list is a signal the class should be refactored.
Wildcard rules for cross-cutting deps. $dice->addRule('*', [...]) applies a rule to every class. Good for things like 'shareInstances' => [PDO::class] so a single connection is threaded through the whole graph. Use type-based keys (Psr\Log\LoggerInterface::class), never named-parameter keys, in wildcard rules.
instanceOf for families. If you have a base controller/command with shared setter injection, define the rule once on the base and have subclasses declare 'instanceOf' => BaseClass::class. It lets you write rules against an abstraction and have concrete subclasses pick them up.
Runtime values at the edge. Anything request-scoped — tenant ID, an authenticated user, an API key — should not live in the rules file. Pass it positionally:
$dice->create(App\Jobs\SyncJob::class, [$tenantId]);
and let the rules fill the object-typed slots. If you need a genuinely dynamic object, register a factory closure as a substitution rather than closing over the container inside your classes.
Laravel integration. Inside a Laravel app you already have a container, so don't run two of them for the same object graph. Two sane shapes: (a) use Dice only in bounded contexts that are container-agnostic — a domain module, an artisan command, a standalone package — and keep the HTTP layer on Laravel's container; (b) bind Dice behind a PSR-11 adapter and resolve it as a service for that one subsystem. What you should not do is have some services in AppServiceProvider and their collaborators in dice.config.php; the split will be invisible to the next developer.
Testing. The rules array is your seam. In setUp(), build a fresh Dice, addRule() over the production entry with ['instance', $fake], and create the subject. No Laravel container bootstrapping, no app()->instance() bookkeeping.
string $apiKey and no default throws. Use '$apiKey' => ['instance', ...] (note the $) for named parameters, or pass it positionally at create-time. Type-hinted substitutions are matched by class name and are far less brittle than named-parameter ones — prefer them.ReflectionException from deep in the recursion and the stack trace points at create(), not at the missing rule. When debugging, work top-down: getRule() the outermost class, then create() each dependency in isolation in tinker.create() hangs or fatals, suspect A → B → A before suspecting Dice.'*' rule keyed on '$logger' will inject into any constructor with a parameter of that name, including unrelated third-party classes. Key wildcards on interfaces.create() does reflection work. For a handful of objects per request that's irrelevant; inside a loop building thousands of short-lived objects it is measurable. Cache shared results or resolve once in a service provider.Dice instance kept in a static or a container singleton carries shared instances across requests — including any request data captured in them. Rebuild the container per request/job, or keep shared services strictly stateless.create($name, $params = [], $forceNewInstance = false) — the third argument bypasses a shared rule. Useful for genuinely per-call objects; easy to forget it exists and end up writing a parallel factory.addRule() twice for the same class deep-merges rather than replaces, so a stray earlier rule can silently survive a later one. getRule() is the only reliable way to see the final shape.['instance', $obj] with objects constructed in the config file.Dice and override create().Dice and overriding create() (or getRule()) is the cleanest hook for cross-cutting concerns like resolution logging. Avoid monkey-patching behaviour into individual rules.How can I help you explore Laravel packages today?