Lightweight PHP 8.4 DI container with autowiring, directory scanning, PHP attributes, and dotenv support.
📖 Full Documentation | Документация на русском | 中文文档
- PSR-11 compatible (
Psr\Container\ContainerInterface) - Autowiring — automatic constructor dependency resolution via reflection
- Directory scanning — point at a directory, all classes are auto-registered
- PHP Attributes —
#[Inject],#[Singleton],#[Transient],#[Lazy],#[Eager],#[Tag],#[Param],#[Exclude],#[AutoconfigureTag] - Autoconfiguration — automatically tag services by interface or attribute (Symfony-style)
- Dotenv — built-in
.envparser with 3-level priority (no external dependencies) - Tagged services — group services by tag and retrieve them as a collection
- Lazy proxies — deferred instantiation via PHP 8.4 native lazy objects
- Compiled container — generate a PHP class with zero reflection at runtime
- Setter injection — configure method calls on services after instantiation
- Circular dependency detection — unsafe cycles detected at build time with clear error messages
- PHP >= 8.4
- psr/container ^2.0
composer require ascetic-soft/wireboxuse AsceticSoft\Wirebox\ContainerBuilder;
$builder = new ContainerBuilder(projectDir: __DIR__);
// Scan a directory — all concrete classes are auto-registered
$builder->scan(__DIR__ . '/src');
// Build the container
$container = $builder->build();
// Resolve any service
$service = $container->get(App\UserService::class);The ContainerBuilder is the main entry point for configuring the container.
$builder = new ContainerBuilder(projectDir: __DIR__);The projectDir is used as the base path for resolving .env files.
Scan directories to auto-register all concrete classes (abstract classes, interfaces, traits, and enums are skipped):
$builder->scan(__DIR__ . '/src');
$builder->scan(__DIR__ . '/modules');Exclude files by glob pattern:
$builder->exclude('Entity/*');
$builder->exclude('*Test.php');
$builder->scan(__DIR__ . '/src');Classes marked with #[Exclude] are skipped automatically:
use AsceticSoft\Wirebox\Attribute\Exclude;
#[Exclude]
class InternalHelper
{
// Will not be registered in the container
}Bind an interface to a concrete implementation:
$builder->bind(LoggerInterface::class, FileLogger::class);When scanning, if an interface has exactly one implementation in the scanned directory, it is auto-bound automatically.
If two or more implementations of the same interface are found, the auto-binding becomes ambiguous and build() will throw a ContainerException. Use an explicit bind() call to resolve the ambiguity:
$builder->scan(__DIR__ . '/Services');
// PaymentInterface has StripePayment and PayPalPayment — ambiguous!
$builder->bind(PaymentInterface::class, StripePayment::class);Alternatively, if you don't need a specific binding but want to suppress the ambiguity error (e.g. the interface is resolved at runtime, or you only use tagged iteration), use excludeFromAutoBinding():
$builder->excludeFromAutoBinding(PaymentInterface::class);
$builder->scan(__DIR__ . '/Services');
// No error — PaymentInterface is excluded from the auto-binding check
$container = $builder->build();Multiple interfaces can be excluded at once:
$builder->excludeFromAutoBinding(
PaymentInterface::class,
NotificationChannelInterface::class,
);Note: Unlike
registerForAutoconfiguration(), this does not apply any autoconfiguration rules (tags, lifetime, etc.) — it only suppresses the ambiguity error.
Register a service with a custom factory closure:
$builder->register(Connection::class, function (Container $c) {
return new Connection(
host: $c->getParameter('db.host'),
port: $c->getParameter('db.port'),
);
});Override or configure individual service definitions:
$builder->register(FileLogger::class)
->transient() // New instance every time
->lazy() // Deferred instantiation
->tag('logger') // Add a tag
->call('setFormatter', [JsonFormatter::class]); // Setter injectionSet parameters that can reference environment variables:
$builder->parameter('db.host', '%env(DB_HOST)%');
$builder->parameter('db.port', '%env(int:DB_PORT)%');
$builder->parameter('app.debug', '%env(bool:APP_DEBUG)%');
$builder->parameter('rate.limit', '%env(float:RATE_LIMIT)%');Supported type casts: string (default), int, float, bool.
Parameters can also contain env expressions embedded in a larger string:
$builder->parameter('dsn', 'mysql:host=%env(DB_HOST)%;port=%env(DB_PORT)%');Wirebox resolves environment variables with 3-level priority (highest first):
| Priority | Source | Description |
|---|---|---|
| 1 | .env.local.php |
Generated by composer dump-env. A PHP file returning an array. Fastest. |
| 2 | $_ENV / getenv() |
Real system environment variables. |
| 3 | .env |
Parsed by built-in DotEnvParser. Development fallback. |
All files are resolved relative to projectDir.
APP_NAME=Wirebox
DB_HOST=localhost
DB_PORT=5432
APP_DEBUG=true
# Comments are supported
QUOTED="hello world"
SINGLE='literal value'
# Variable interpolation
BASE_PATH=/opt
FULL_PATH="${BASE_PATH}/app"
# Export prefix is stripped
export SECRET_KEY=abc123For production, use Symfony's composer dump-env to generate .env.local.php:
composer dump-env prodThis creates a PHP file that returns an array — no file parsing at runtime.
Marks a class as singleton (this is the default behavior, use for explicitness):
use AsceticSoft\Wirebox\Attribute\Singleton;
#[Singleton]
class DatabaseConnection
{
}A new instance is created on every get() call:
use AsceticSoft\Wirebox\Attribute\Transient;
#[Transient]
class RequestContext
{
}Return a lightweight proxy immediately; the real instance is created only when a property or method is first accessed. Uses PHP 8.4 native lazy objects (ReflectionClass::newLazyProxy):
use AsceticSoft\Wirebox\Attribute\Lazy;
#[Lazy]
class HeavyReportGenerator
{
public function __construct(
private Connection $db,
private CacheInterface $cache,
) {
// expensive setup...
}
}Can be combined with #[Transient] to create a new lazy proxy on every get() call.
The same behavior is available via the fluent API:
$builder->register(HeavyReportGenerator::class)->lazy();Lazy proxies are fully supported by the compiled container.
ContainerBuilder enables lazy mode by default — all services without an explicit #[Lazy] or #[Eager] attribute are created as lazy proxies. You can disable this:
$builder->defaultLazy(false);Opt out of lazy instantiation when the container's default lazy mode is enabled:
use AsceticSoft\Wirebox\Attribute\Eager;
#[Eager]
class AppConfig
{
// Always created immediately, even when defaultLazy is on
}The same behavior is available via the fluent API:
$builder->register(AppConfig::class)->eager();Tag a class for grouped retrieval. Repeatable:
use AsceticSoft\Wirebox\Attribute\Tag;
#[Tag('event.listener')]
#[Tag('audit')]
class UserCreatedListener
{
}Retrieve tagged services:
foreach ($container->getTagged('event.listener') as $listener) {
$listener->handle($event);
}Automatically tag all classes that implement an interface or are decorated with a custom attribute. Place #[AutoconfigureTag] on the interface or attribute class.
On an interface — all implementing classes receive the tag:
use AsceticSoft\Wirebox\Attribute\AutoconfigureTag;
#[AutoconfigureTag('command.handler')]
interface CommandHandlerInterface
{
public function __invoke(object $command): void;
}
// Automatically receives the 'command.handler' tag when scanned
class CreateUserHandler implements CommandHandlerInterface
{
public function __invoke(object $command): void
{
// ...
}
}On a custom attribute — all classes decorated with that attribute receive the tag:
use AsceticSoft\Wirebox\Attribute\AutoconfigureTag;
#[Attribute(Attribute::TARGET_CLASS)]
#[AutoconfigureTag('scheduler.task')]
class AsScheduled {}
// Automatically receives the 'scheduler.task' tag when scanned
#[AsScheduled]
class DailyReportTask
{
public function run(): void { /* ... */ }
}Repeatable — multiple tags can be applied:
#[AutoconfigureTag('command.handler')]
#[AutoconfigureTag('auditable')]
interface CommandHandlerInterface {}For more control (lifetime, lazy, multiple tags), use registerForAutoconfiguration() on the builder:
$builder->registerForAutoconfiguration(EventListenerInterface::class)
->tag('event.listener')
->singleton()
->lazy();Any class implementing EventListenerInterface found during scan() will automatically get the event.listener tag, be configured as a singleton, and use lazy proxies.
This also works with attributes:
$builder->registerForAutoconfiguration(AsScheduled::class)
->tag('scheduler.task')
->transient();Autoconfiguration makes it easy to set up command and query handlers:
use AsceticSoft\Wirebox\Attribute\AutoconfigureTag;
#[AutoconfigureTag('command.handler')]
interface CommandHandlerInterface
{
public function __invoke(object $command): void;
}
#[AutoconfigureTag('query.handler')]
interface QueryHandlerInterface
{
public function __invoke(object $query): mixed;
}
// Handlers — no manual tagging needed
class CreateUserHandler implements CommandHandlerInterface
{
public function __invoke(object $command): void { /* ... */ }
}
class DeleteUserHandler implements CommandHandlerInterface
{
public function __invoke(object $command): void { /* ... */ }
}
class GetUserHandler implements QueryHandlerInterface
{
public function __invoke(object $query): mixed { /* ... */ }
}Build and retrieve. Autoconfigured interfaces are excluded from the ambiguous auto-binding check, so multiple implementations work seamlessly:
$builder = new ContainerBuilder(projectDir: __DIR__);
$builder->scan(__DIR__ . '/src');
// No need for bind() — CommandHandlerInterface is autoconfigured
$container = $builder->build();
// Iterate all command handlers
foreach ($container->getTagged('command.handler') as $handler) {
// CreateUserHandler, DeleteUserHandler
}
// Iterate all query handlers
foreach ($container->getTagged('query.handler') as $handler) {
// GetUserHandler
}Specify a concrete implementation for a type-hinted parameter:
use AsceticSoft\Wirebox\Attribute\Inject;
class NotificationService
{
public function __construct(
#[Inject(SmtpMailer::class)]
private MailerInterface $mailer,
) {
}
}Inject a scalar value from environment variables:
use AsceticSoft\Wirebox\Attribute\Param;
class DatabaseService
{
public function __construct(
#[Param('DB_HOST')] private string $host,
#[Param('DB_PORT')] private int $port,
#[Param('APP_DEBUG')] private bool $debug = false,
) {
}
}The value is automatically cast to the parameter's type hint (string, int, float, bool).
Exclude a class from auto-registration during directory scanning:
use AsceticSoft\Wirebox\Attribute\Exclude;
#[Exclude]
class InternalHelper
{
}$service = $container->get(UserService::class);
$exists = $container->has(UserService::class);$loggers = $container->getTagged('logger'); // iterable<object>$host = $container->getParameter('db.host');
$all = $container->getParameters();The container registers itself, so you can type-hint it:
use Psr\Container\ContainerInterface;
class ServiceLocator
{
public function __construct(
private ContainerInterface $container,
) {
}
}For production, compile the container to a PHP class. This eliminates reflection at runtime:
$builder = new ContainerBuilder(projectDir: __DIR__);
$builder->scan(__DIR__ . '/src');
$builder->bind(LoggerInterface::class, FileLogger::class);
$builder->parameter('db.host', '%env(DB_HOST)%');
// Generate the compiled container
$builder->compile(
outputPath: __DIR__ . '/var/cache/CompiledContainer.php',
className: 'CompiledContainer',
namespace: 'App\Cache',
);Use the compiled container in production:
require_once __DIR__ . '/var/cache/CompiledContainer.php';
$container = new App\Cache\CompiledContainer();
$service = $container->get(UserService::class);The compiled container:
- Extends
AsceticSoft\Wirebox\Compiler\CompiledContainer - Implements
Psr\Container\ContainerInterface - Has a dedicated factory method for each service
- Supports singleton caching, bindings, parameters, and tags
- Does not support factory closures (they require runtime evaluation)
Wirebox detects circular dependencies at build time (build() / compile()) and throws CircularDependencyException for unsafe cycles before the container is ever used.
A circular dependency is safe only when all services in the cycle are lazy singletons. The proxy is cached before real instantiation begins, so when the dependency chain loops back, it finds the proxy in the cache instead of re-entering construction:
// Safe — both are lazy singletons (the default)
#[Lazy]
class ServiceA
{
public function __construct(public readonly ServiceB $b) {}
}
#[Lazy]
class ServiceB
{
public function __construct(public readonly ServiceA $a) {}
}
$container = $builder->build(); // OK
$a = $container->get(ServiceA::class);
assert($a->b->a === $a); // same proxy| Scenario | Result |
|---|---|
| All services are lazy singletons | Safe — proxy cached before instantiation |
| Any service is eager | Unsafe — Autowirer hits the same class twice |
| Any service is lazy transient | Unsafe — proxy is not cached, infinite recursion |
Unsafe cycles are reported with a clear message:
Circular dependency detected: ServiceA -> ServiceB -> ServiceA.
All services in a circular dependency must be lazy singletons.
Unsafe: ServiceB (not lazy)
Note: Factory-based definitions (
register(..., fn() => ...)) are skipped during cycle analysis because their dependencies cannot be determined statically.
Wirebox throws specific exceptions for common issues:
| Exception | When |
|---|---|
NotFoundException |
Service not found and cannot be auto-wired |
AutowireException |
Cannot resolve a constructor parameter |
CircularDependencyException |
Unsafe circular dependency detected at build or runtime |
ContainerException |
General container error (e.g. ambiguous bindings) |
All exceptions implement Psr\Container\ContainerExceptionInterface.
use AsceticSoft\Wirebox\Exception\CircularDependencyException;
try {
$builder->build();
} catch (CircularDependencyException $e) {
// "Circular dependency detected: ServiceA -> ServiceB -> ServiceA. ..."
echo $e->getMessage();
}// bootstrap.php
use AsceticSoft\Wirebox\ContainerBuilder;
$builder = new ContainerBuilder(projectDir: __DIR__);
// Exclude entities and migrations from the container
$builder->exclude('Entity/*');
$builder->exclude('Migration/*');
// Scan application classes
$builder->scan(__DIR__ . '/src');
// Explicit bindings where needed
$builder->bind(LoggerInterface::class, FileLogger::class);
$builder->bind(CacheInterface::class, RedisCache::class);
// Environment-based parameters
$builder->parameter('db.host', '%env(DB_HOST)%');
$builder->parameter('db.port', '%env(int:DB_PORT)%');
$builder->parameter('app.debug', '%env(bool:APP_DEBUG)%');
// Custom factory
$builder->register(PDO::class, function ($c) {
return new PDO(
sprintf('mysql:host=%s;port=%d;dbname=app',
$c->getParameter('db.host'),
$c->getParameter('db.port'),
),
);
});
// Build and use
$container = $builder->build();
$app = $container->get(App\Kernel::class);
$app->run();composer install
vendor/bin/phpunitMIT