Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Di Container Laravel Package

sanmai/di-container

A straightforward PSR-11 dependency injection container with zero-configuration autowiring. It resolves constructor dependencies via reflection, reuses created objects, maps interfaces to concrete implementations, and supports builder objects for complex construction.

View on GitHub
Deep Wiki
Context7

Getting Started

Be honest about scope before you write a line of code: on a Laravel project you already have Illuminate\Container, and this package is not a replacement. The realistic entry point for a Laravel developer is a dependency-free subpackage — a library, domain module, CLI tool, or worker component that ships inside your monorepo (or on Packagist) and must not require illuminate/*.

composer require sanmai/di-container

First read, in this order:

  1. README.md → Quick Start, Builder Objects, Design Philosophy.
  2. src/Container.php → the whole container; read the get(), set(), bind(), inject(), has() methods. It is small enough to audit in one sitting, which matters for a 7-star package.
  3. DIContainer\Builder → a one-method interface (build()), covariant template.

First use case — a component that cannot see the framework:

use DIContainer\Container;

$container = new Container();

// Zero config: reflection wires the constructor
$report = $container->get(ReportGenerator::class);

If your very first call throws, it is almost always a scalar constructor argument (see Gotchas). Fix that by registering a factory, not by fighting the reflection:

$container = new Container([
    ReportGenerator::class => fn(Container $c) => new ReportGenerator(
        $c->get(ReportRepositoryInterface::class),
        new Filesystem('/var/reports'),
        'daily',
    ),
]);

Day-to-day, this container lives in one file — a composition root — that you keep out of Laravel's service providers. Everything else in your code type-hints interfaces and never touches the container.

Implementation Patterns

1. One composition root per boundary

Keep exactly one place that news up the container, per library/module. Laravel's AppServiceProvider composes framework services; this file composes framework-free ones. When the package is eventually swapped (PHP-DI, Symfony, Illuminate), you edit this file only.

// src/Composition.php  — the only file that knows about DIContainer
final class Composition
{
    public static function make(): Container
    {
        return new Container(
            values: [
                LoggerInterface::class      => FileLogger::class,      // concrete, autowired
                ClockInterface::class       => SystemClock::class,
                MailerInterface::class      => MailerBuilder::class,  // Builder class-string
                ReportGenerator::class      => fn(Container $c) => new ReportGenerator(
                    $c->get(ReportRepositoryInterface::class),
                    $c->get(ClockInterface::class),
                    'daily',
                ),
            ],
            bindings: [
                'report.dsn' => fn() => getenv('REPORT_DSN'),
            ],
        );
    }
}

Note the two arrays: values are keyed by class-string and get type-checked against the factory's return type; bindings accept dotted/string IDs and skip that check.

2. Builders instead of long closures

The Builder interface is the single most valuable idea here. Complex construction (DSN parsing, client configuration, retry policies) becomes a testable, injectable class rather than an anonymous closure you cannot unit-test:

use DIContainer\Builder;

/** @implements Builder<MailerInterface> */
final class MailerBuilder implements Builder
{
    public function __construct(
        private readonly ConfigProvider $config,
        private readonly LoggerInterface $logger,
    ) {}

    public function build(): MailerInterface
    {
        return new SmtpMailer($this->config->mailerDsn(), $this->logger);
    }
}

Register the class-string (MailerInterface::class => MailerBuilder::class); the container instantiates the builder with autowiring, calls build(), and caches the result. Add @implements Builder<MailerInterface> so PHPStan enforces the return type — the template is covariant precisely for this.

3. Interface → implementation without explicit wiring

You do not have to map interfaces to implementations if only one registered factory/builder produces a compatible object. In practice, be explicit for anything load-bearing and let autowiring pick up the rest:

// explicit: unambiguous, greppable, survives a second implementation
$container->set(LoggerInterface::class, fn() => new FileLogger('/tmp/app.log'));

4. Bridging Laravel services into a framework-free component

The one place this container genuinely pays off in a Laravel codebase: your library takes PSR-11 style dependencies, and Laravel supplies the concrete instances. Use inject() to hand over objects Laravel already built, instead of re-constructing them:

// AppServiceProvider::register()
$this->app->singleton('reports.container', function ($app) {
    $container = \Reports\Composition::make();

    // push framework-built instances in, don't rebuild them
    $container->inject(\Psr\Log\LoggerInterface::class, $app->make('log')->channel('reports'));
    $container->inject(\Illuminate\Contracts\Config\Repository::class, $app->make('config'));

    return $container;
});

Bind under an app-specific key ('reports.container') rather than aliasing Psr\Container\ContainerInterface globally — hijacking that key risks surprising any package that resolves it.

5. Self-injection as a controlled escape hatch

Since 0.1.8 the container can inject itself (and is registered as ContainerInterface). That means a service can declare ContainerInterface $container and lazily pull things at call time:

final class ReportGenerator
{
    public function __construct(private readonly ContainerInterface $container) {}

    public function run(): void
    {
        $formatter = $this->container->get(Formatter::class); // resolved on demand
    }
}

Use this when construction is genuinely conditional (tenant-specific formatter, feature flag). Otherwise prefer constructor arguments — the README is explicit that container-as-service-llocator is a compromise, not a design goal.

6. Testing: throw the container away each time

It costs nanoseconds to build, and every object is a singleton inside it. So:

protected function setUp(): void
{
    $this->container = new Container([
        LoggerInterface::class => fn() => new NullLogger(),
    ]);
}

Swap one collaborator by overriding one key — the rest of the graph still autowires. This is also how you keep queue workers and Octane processes honest: do not hold a container across requests/jobs. Rebuild per unit of work.

7. Feature work in a Laravel repo — where it actually lands

  • Composer packages under packages/* that are framework-agnostic → this container in the package's own tests and examples.
  • Standalone CLI/scheduler binaries in the repo (cron scripts, deployment tooling) → this container, no Laravel bootstrap.
  • Anything with routes, jobs, events, facades, artisan → Laravel's container, full stop.

Gotchas and Tips

Constructor-only injection, and strictly typed constructors. No property/setter injection, no method injection, no variadics, no union/intersection types. A constructor asking for string $dsn, array $options, or ?Logger $logger = null cannot be autowired — register a factory. This is the number-one source of get() failures on day one.

Interface resolution is "exactly one, or exception." Two factories producing the same interface and the resolution fails rather than guessing. That is a feature, but it means adding an implementation can break an unrelated get() far away. Register explicit Interface::class => ... entries for anything you cannot afford to have go ambiguous.

No circular dependency detection. Two classes requiring each other produce unbounded recursion (stack overflow / OOM), not a clean error. Mitigation is trivial and should be automatic: one smoke test that resolves your top-level entry point. That test is your cycle detector.

Everything is a singleton and there is no forget(). Once resolved, an object is frozen in place. Order of inject()/set() calls does matter for this reason: injecting a logger after something that depends on it has already been built changes nothing. Register everything up front.

Resolution order is lazy, registration order is not. The README's "order is not important" holds for the constructor arrays — but a factory closure that calls $c->get(...) executes at resolution time, so it will happily see services you registered later. Do not rely on that in reverse (dependencies resolved eagerly at construction time inside a factory are already fixed).

bind() bypasses type validation entirely. There is no class-string check on the ID, so $container->get('app.repositry') is a runtime NotFoundException and PHPStan cannot save you. Treat dotted IDs as public API: define them as constants in one class and assert has() in a test. Note has() correctly considers builders only since 0.1.11 — pin ^0.1.11 or later.

Keep the composition root out of src/. If your library classes reference DIContainer\*, you have coupled the library to this specific implementation and lost the main justification for choosing it. Interfaces in src/, new Container in a factory/bootstrap class, tests against the factory.

Pin the version and plan an exit. Seven stars, zero dependents, a single maintainer, and a pre-1.0 version line. That is fine for an internal, framework-free module — it is not fine as load-bearing infrastructure you cannot replace. Concretely: keep sanmai/di-container in require-dev/a leaf package only, and depend on psr/container (not on DIContainer) in your own signatures. Then swapping to php-di/php-di or a hand-rolled container is a one-file change.

Benchmarking and performance. The package ships a PHPBench suite (make benchmark) with OPcache/JIT. If you are evaluating it for hot paths, note that reflection costs are paid once per class per container instance — which is another argument for a per-process container rather than a per-request one in long-running workers, offset by the singleton-state caveat above.

Debugging technique. When autowiring fails, has() lies to you slightly (it returns true for resolvable builders even when their own dependencies won't resolve). Resolve the tree bottom-up manually — $c->get(Dependency::class) for each constructor argument — and the failing scalar/untyped leaf reveals itself immediately. There are no error messages enumerating "missing parameter," so this manual walk is faster than reading stack traces.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel