Compiled dependency injection container — fork of nette/di adding tag-based service identity, #[Inject(tag:)], NEON @Type#tag references, and array<string, T> bag-of-services autowire.

Maintainers

Package info

github.com/delacry/di

Homepage

pkg:composer/delacry/di

Statistics

Installs: 80

Dependents: 0

Suggesters: 0

Stars: 0


README

A fork of nette/di that adds tag-based dependency injection for services of the same type, so you can register multiple implementations of an interface and pick the right one at the injection site by tag.

Why this fork exists

nette/di#321 - InjectExtension: added support for injecting services by tags - has been open against upstream since May 2025 with no movement. This fork picks the feature up, ships it, and extends the model further: a first-class identity tag on every service definition, a new canonical Container::get() lookup, NEON @Type#tag reference syntax, an O(1) precomputed (type, tag) index on the compiled container, and deterministic ordering of autowired collections via per-definition priority/before/after.

For everything else about Nette DI - service definitions, factories, decorators, NEON syntax, autowiring rules, extension authoring - see nette/di's documentation. All of it works the same here. This README only covers what's different.

What's new

#[Inject(tag: 'X')] on properties, constructor parameters, and inject methods

use Nette\DI\Attributes\Inject;

class OrderService
{
    public function __construct(
        #[Inject(tag: 'fast')]
        public readonly CacheInterface $cache,
    ) {}
}

Same attribute works on properties:

class OrderService
{
    #[Inject(tag: 'fast')]
    public CacheInterface $cache;
}

…and on inject*() method parameters:

class OrderService
{
    public function injectCache(#[Inject(tag: 'fast')] CacheInterface $cache): void
    {
        $this->cache = $cache;
    }
}

#[Inject] on a constructor or inject-method parameter requires a tag - untagged parameters are autowired by native type already, so a bare #[Inject] there is redundant and throws at compile time.

Single-string identity tag per service

services:
    cache.fast:
        factory: App\Cache\RedisCache
        tag: fast

    cache.slow:
        factory: App\Cache\FileSystemCache
        tag: slow

    fallback: App\Cache\NullCache
    # untagged services are implicitly tagged "default"

Or via the fluent API:

$builder->addDefinition('cache.fast')
    ->setType(App\Cache\RedisCache::class)
    ->setTag('fast');

The single-string tag is intentionally distinct from upstream's existing multi-key tags: { … } metadata bag (which is unchanged and still works). Tags here are an identity discriminator used together with the service type; tags: is a free-form metadata bag used by extensions like LocatorDefinition's tagged: selector.

Container::get($type, ?$tag) canonical lookup

$cache = $container->get(CacheInterface::class, 'fast'); // RedisCache
$cache = $container->get(CacheInterface::class);          // NullCache (untagged → default)

Backed by a precomputed array<class-string, array<tag, list<name>>> index baked into the generated container at compile time. The hot path is one hash lookup + one count() + one getService() - ~108 ns/op on a 10-implementation interface with tag filtering, ~9.2M ops/s on a single core (measured on PHP 8.4, no opcache JIT). For comparison, plain getService($name) by direct name lookup measures ~40 ns/op.

get() throws MissingServiceException on miss or ambiguity. For a nullable miss, use getOrNull($type, ?$tag) - same fast path, returns null instead of throwing when nothing matches. Ambiguity (multiple services match) still throws on getOrNull() because it's a programming error, not a "does this exist?" question.

$cache = $container->getOrNull(CacheInterface::class, 'optional');  // null if not registered

NEON @Type#tag reference syntax

services:
    orderService:
        factory: OrderService
        arguments:
            cache: @App\Cache\CacheInterface#fast

Any reference value containing a backslash is treated as a type reference (this is upstream Nette's rule, not new in the fork), so namespaced FQNs like @App\Cache\CacheInterface work as-is. A leading \ is only needed for global-namespace types (@\CacheInterface#fast) to disambiguate them from a service-name reference. NEON's own tokenizer accepts the #tag suffix unquoted, so no escaping required.

Polymorphic resolution

All three of these return the same instance when cache.fast is the only 'fast'-tagged service implementing CacheInterface:

$container->get(CacheInterface::class, 'fast');
$container->get(RedisCache::class, 'fast');
$container->get(RedisCache::class); // RedisCache is the only one

The autowiring index registers each service under all its parent classes and interfaces; the tag filter narrows the candidates to the matching identity.

Tag-keyed bag-of-services autowire: array<string, T>

A constructor parameter PHPDoc-typed as array<string, T> is autowired as a tag-keyed map of every autowired service implementing T:

class PoolRegistry
{
    /**
     * @param array<string, CacheInterface> $pools
     */
    public function __construct(
        public readonly array $pools,
    ) {}
}

Given the services above, $pools is filled with ['fast' => $redisCache, 'slow' => $fsCache, 'default' => $fallback]. The generated container emits the array literal at compile time - no runtime aggregation.

The pre-existing T[], list<T> and array<int, T> patterns continue to autowire as numerically-keyed lists, unchanged from upstream. If two services of the same type share the same identity tag, the array<string, T> autowire throws at compile time (the tag → service mapping must be unambiguous).

Deterministic collection ordering: priority / before / after

When a parameter autowires a collection of a type (T[], list<T>, array<int, T>, or the tag-keyed array<string, T> above), the collected services come back in registration order - which, for attribute- or filesystem-discovered services, is not reproducible across machines. Each Definition carries optional ordering metadata that makes the order deterministic:

$builder->addDefinition('appRouter')
    ->setType(App\AppRouter::class)
    ->setPriority(100);                    // higher is collected first

$builder->addDefinition('adminRouter')
    ->setType(Admin\AdminRouter::class)
    ->setBefore([App\AppRouter::class])    // relative: collected before AppRouter
    ->setAfter([Core\CoreRouter::class]);  // …and after CoreRouter

The order is resolved by Nette\DI\DefinitionOrdering and applied wherever a collection of a type is assembled - autowired collection parameters as well as direct ContainerBuilder::findByType() / findAutowired() calls all return services in this order:

  • priority (?int, default null → treated as 0) - an absolute tier; higher is collected first.
  • before / after (list<class-string>) - relative, hard constraints against other collected services. They become edges in a topological sort; priority, then the service's type FQCN, then its name break ties among otherwise-unordered services.

Rules:

  • A collection in which nothing carries ordering metadata is returned in registration order, untouched - existing code and other Nette DI users see no behavioural change.
  • before/after match by is_a, so referencing an interface orders this service against every collected implementation of it.
  • A reference that matches no collected service is silently ignored - that's how a package can declare "I run before X" when X may not be installed.
  • A cycle (A before B, B before A) throws ServiceCreationException at compile time, naming the tangled services.

These are storage-only primitives: the engine reads them, but never sets them itself. A higher layer (e.g. an attribute-driven compiler pass) decides what they mean and calls the setters - the same division of labour as the multi-key tags: bag.

What's removed

  • Legacy @inject docblock annotation fallback in InjectExtension (use the #[Inject] attribute)
  • Legacy @var type-hint fallback for inject properties (use native type hints)
  • Helpers::parseAnnotation() (no remaining callers after the @inject strip)
  • Pre-3.0 class aliases: Nette\DI\ServiceDefinition, Nette\DI\Statement, Nette\DI\Config\IAdapter
  • Definition::generateMethod() (callers updated to use Definition::generateCode())
  • Definition::isAutowired() (use getAutowired())

Definition::setClass() / getClass() are kept as deprecated wrappers because tracy/tracy's DI bridge still calls them.

Deprecated (still functional)

  • Container::getService($name) - @deprecated docblock points at Container::get($type, $tag). Docblock-only deprecation; no runtime E_USER_DEPRECATED is emitted, since get() itself calls getService() internally.

Backward compatibility

This fork keeps the engine permissive:

  • addDefinition($name, …) with a non-null name still works
  • services: { foo: Bar } NEON keys still register foo as the service name
  • Collection ordering is opt-in: a collection with no priority/before/after is returned in registration order, exactly as before
  • All existing tests pass

Tag-aware features and the ordering primitives are strictly additive. Calling code that doesn't use them behaves exactly like upstream nette/di v3.3.

Status

  • Based on upstream nette/di v3.3 (commit d16957a).
  • Not tracking upstream - upstream branches force-push, so changes from upstream are cherry-picked when needed.
  • Tests: 178 pass (was 157 on the v3.3 baseline; the tag features, the array<string, T> bag autowire, and deterministic collection ordering are the additions).
  • PHP requirement: 8.4 – 8.5 (bumped from upstream's 8.2 – 8.5; the fork uses asymmetric property visibility for Definition::$tag and other 8.4-only conveniences). If you need 8.2 or 8.3 compatibility, stay on upstream nette/di.

Documentation

For installation, service definitions, factories, decorators, NEON syntax, autowiring rules, extension authoring - read nette/di's documentation. Only the additions above are fork-specific.

License

BSD-3-Clause / GPL-2.0 / GPL-3.0 (same as upstream nette/di).