PHP 8.3+ / PSR-11

PHPInjector

A compact dependency injector for classes and callables. Reflection does the inspection; you decide the explicit inputs, providers, and lifecycle.

A useful first line
<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use PHPInjector\DI\Injector;

final class MyService
{
}

$service = Injector::inject(MyService::class);
echo get_class($service);
Expected output
MyService
01 / Start here

Keep the graph legible.

Register only the values that are genuinely external to the object graph. Let type declarations describe the rest.

Install

The runtime has one dependency: the PSR-11 container contract.

composer require dvictorjhg/php-injector

Providers

A provider is a stored value or a lazy class string. Closures are supplied as values, not executed as factories.

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use PHPInjector\DI\Injector;

final class Logger
{
}

$logger = new Logger();
$dsn = 'mysql:host=db;dbname=app';

$injector = new Injector();
$injector->addProvider(Logger::class, $logger);
$injector->addProvider('db.dsn', $dsn);

echo Injector::inject('db.dsn');
Expected output
mysql:host=db;dbname=app
Identifier How it is used
Logger::class Matches a parameter type.
db.dsn A parameter marked #[Inject('db.dsn')] receives this value.
Logger::class => $logger A supplied value is returned as registered.
One container, one graph

A new injector becomes globally active. It checks local providers first, then its explicit parent or the previously active injector. Constructing an empty injector does not reset the chain.

02 / Lifecycle

Choose where identity lives.

The injector caches concrete classes by default. Use the Transient attribute when each resolution needs a fresh object, or implement the Singleton contract when the class should own its instance.

Transient

Add #[Transient] to skip injector caching. Each resolution that constructs the class gets a fresh instance.

Singleton

Implement Singleton when the class should own a shared instance. The injector calls getInstance() instead of its constructor.

Per-call

Pass an explicit instance when one resolution needs a fresh dependency. #[Transient] applies to the whole class and does not replace Singleton.

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use PHPInjector\DI\Attributes\Transient;
use PHPInjector\DI\Injector;

#[Transient]
final class RequestStamp
{
}

$first = Injector::inject(RequestStamp::class);
$second = Injector::inject(RequestStamp::class);

var_dump($first === $second);
Expected output
bool(false)
03 / Resolution

Every parameter has a trail.

The resolver follows one ordered path. The first matching source wins, which keeps a complex graph inspectable in a debugger or a code review.

01 first

Explicit arguments

stop

The optional second argument to Injector::inject() is checked first. Keys can be a type, parameter name, or numeric position.

  1. 01 $args[Logger::class]

    type

  2. 02 $args['logger']

    name

  3. 03 $args[0]

    position

next
02 attribute

#[Inject('id')]

chain

A parameter-level identifier selects a named provider after no explicit argument matches.

next
03 type

Typed provider

chain

The declared class or interface name is looked up in the active injector and then its parent chain.

next
04 default

PHP default

stop

A declared default value is used when no explicit value, attribute, or typed provider is available.

next
05 error

InjectorException

throw

A required parameter with no source fails loudly. There is no hidden source or service locator detour.

Step 1 / Explicit arguments

Same dependency, three valid forms

Step 1 maps the same dependency by type key, parameter name, or numeric position. Use one form per call.

01 Logger::class => $logger type key
02 'logger' => $logger parameter name
03 0 => $logger numeric position
Same target, three calls
<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use PHPInjector\DI\Injector;

final class Logger
{
    public function __construct(public string $channel)
    {
    }
}

$logger = new Logger('worker');
$target = static fn (Logger $logger): string => $logger->channel;
$byType = Injector::inject($target, [Logger::class => $logger]);
$byName = Injector::inject($target, ['logger' => $logger]);
$byPosition = Injector::inject($target, [$logger]);
echo $byType . PHP_EOL . $byName . PHP_EOL . $byPosition;
Expected output
worker
worker
worker

Full resolution in one target

Run the same target twice: first with an explicit report, then without it. The first available source supplies each parameter, and the final call shows the required-parameter exception.

Example
<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use PHPInjector\DI\Attributes\Inject;
use PHPInjector\DI\Injector;
use PHPInjector\Exceptions\InjectorException;

final class Logger
{
    public function __construct(public string $channel = 'app')
    {
    }
}

final class ReportRunner
{
    public function run(
        Logger $logger,
        #[Inject('env')] string $environment,
        string $report = 'summary',
    ): string {
        return "{$logger->channel}:{$environment}:{$report}";
    }
}

$injector = new Injector([
    Logger::class,
    'env' => 'production',
]);

echo Injector::inject([ReportRunner::class, 'run'], [
    'report' => 'sales',
]) . PHP_EOL;
echo Injector::inject([ReportRunner::class, 'run']) . PHP_EOL;

$required = static fn (string $token): string => $token;

try {
    Injector::inject($required);
} catch (InjectorException $exception) {
    echo $exception->getMessage();
}
Expected output
app:production:sales
app:production:summary
No provider or value found for parameter: $token at position 0.
  1. $report uses the explicit per-call $args value.
  2. $environment uses #[Inject('env')] to find production.
  3. $logger uses the registered Logger provider.
  4. The second call uses the PHP default summary for $report.
  5. $token has no source, so PHPInjector throws InjectorException.
04 / Targets

Reflect the thing you already have.

The public entry point accepts the common PHP callable shapes, then routes each one through the same parameter resolver.

  • Class string Constructs the class and resolves its constructor.
  • Method array Invokes an instance or static method with resolved parameters.
  • Callable string Reflects a function name such as strlen.
  • Closure Reflects and invokes an inline function.
  • Invokable object Uses the object's __invoke method.
<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use PHPInjector\DI\Injector;

final class Logger
{
    public function __construct(public string $channel = 'app')
    {
    }
}

final class ReportJobs
{
    public static function warm(Logger $logger): string
    {
        return "warming {$logger->channel}";
    }
}

$injector = new Injector([Logger::class]);
echo Injector::inject('ReportJobs::warm') . PHP_EOL;
echo Injector::inject(static fn (Logger $logger): string => $logger->channel);
Expected output
warming app
app

Variadics stay positional

For function sum(int ...$values), numeric values from the variadic position onward are collected and passed as separate arguments.

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use PHPInjector\DI\Injector;

echo Injector::inject(
    fn (int ...$values): int => array_sum($values),
    [2, 3, 5],
);
Expected output
10
Recipes / Executable examples

Providers, containers, and boundaries.

Run each complete PHP example independently from the project root after composer install. The documentation check compares actual output with the output shown here.

Alias an interface and inject configuration

Register constructor dependencies explicitly. Direct class targets can be constructed without registration, but their required typed parameters need providers.

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use PHPInjector\DI\Attributes\Inject;
use PHPInjector\DI\Injector;

interface Formatter
{
    public function format(string $value): string;
}

final class TextFormatter implements Formatter
{
    public function format(string $value): string
    {
        return strtoupper($value);
    }
}

final class Report
{
    public function __construct(
        public Formatter $formatter,
        #[Inject('env')] public string $environment,
    ) {
    }
}

$injector = new Injector([
    Formatter::class => TextFormatter::class,
    'env' => 'production',
]);
$report = Injector::inject(Report::class);
echo $report->formatter->format($report->environment) . PHP_EOL;
var_dump(Injector::inject(TextFormatter::class) === $report->formatter);
Expected output
PRODUCTION
bool(true)

Use the PSR-11 store directly

Container stores values without constructing classes or invoking closures. Null is a present value; missing identifiers throw NotFoundException. set() replaces entries and unset() removes them.

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use PHPInjector\Container\Container;
use Psr\Container\NotFoundExceptionInterface;

$container = new Container(['env' => 'dev', 'optional' => null]);
var_dump($container->has('optional'));
$container->set('env', 'production');
echo $container->get('env') . PHP_EOL;
$container->unset('env');
echo count($container) . PHP_EOL;
try {
    $container->get('env');
} catch (NotFoundExceptionInterface $exception) {
    echo $exception->getMessage();
}
Expected output
bool(true)
production
1
No entry was found for env identifier.

Inject a closure as a value

Resolving a closure provider returns the closure itself. Invoke it in your consumer, or pass a callable directly to Injector::inject() to execute it with resolved parameters.

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use PHPInjector\DI\Injector;

$format = static fn (string $value): string => strtoupper($value);
$injector = new Injector([Closure::class => $format]);
var_dump(Injector::inject(Closure::class) === $format);
echo Injector::inject(
    static fn (Closure $format): string => $format('ready'),
);
Expected output
bool(true)
READY

Resolution and lifecycle limits

  • Only registered non-builtin named types are looked up automatically. Scalars, callable, untyped, union, intersection, and DNF parameters need explicit arguments, #[Inject], or defaults.
  • The newest injector is globally active. A null parent uses the previous injector; it does not create an isolated root.
  • Cached objects ignore later constructor arguments. Use #[Transient] or supply the dependency object explicitly when identity must change.
  • No cycle detection or request scope is provided. Do not create circular provider aliases or dependency graphs.
  • Keep long-running workers free of request-specific cached services. Singleton::getInstance() is managed by your class, not reset by the injector.
05 / API

Small surface, sharp edges.

The API is intentionally short. The useful detail is in the contracts between the injector, its providers, and reflection.

Injector::inject Resolve a class, callable, closure, method array, or invokable object. The second argument supplies per-call values.

$injector->addProvider(string $id, mixed $value): void

Register or replace a stored value or lazy class string in this injector.

Source
new Injector(array|Container $providers = [], ?Injector $parent = null)

Creates the active injector. The default parent is the previously active injector; an explicit parent selects another chain.

Source
Injector::inject($target, array $args = []): mixed

Resolves through the active injector. Missing string targets or required parameters raise InjectorException. There is no public getProvider() or hasProvider() API.

Source
PHPInjector\DI\inject()

Global function entry point for Injector::inject().

Source
Container

Use the PSR-11 container directly when a provider map is the right abstraction. It supports get, has, and set.

Source
#[Inject('id')]

Annotate one parameter with a named provider identifier.

Source
#[Transient]

Override singleton caching for one class declaration.

Source
Singleton

Implement getInstance() to supply a class-managed shared instance.

Source
06 / Development

Read the source. Run the checks.

This project keeps its feedback loop deliberately plain: Composer scripts, strict static analysis, and a focused unit suite.

Commands

composer validate --strict
composer check-platform-reqs
php tools/check-docs.php
composer analyse
composer test
composer test:coverage
composer coverage:check

The documentation check requires PHP DOM and proc_open. It executes marked HTML examples and README examples with expected output in separate PHP processes. Coverage additionally requires Xdebug or PCOV.

Project links