ascetic-soft / wirebox
Lightweight, zero-config PSR-11 dependency injection container for PHP 8.4+ with autowiring, PHP attributes, compiled container generation, and built-in dotenv support
Installs: 1
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/ascetic-soft/wirebox
Requires
- php: >=8.4
- psr/container: ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.0
README
Lightweight PHP 8.4 DI container with autowiring, directory scanning, PHP attributes, and dotenv support.
π Full Documentation | ΠΠΎΠΊΡΠΌΠ΅Π½ΡΠ°ΡΠΈΡ Π½Π° ΡΡΡΡΠΊΠΎΠΌ | δΈζζζ‘£
Features
- 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
Requirements
- PHP >= 8.4
- psr/container ^2.0
Installation
composer require ascetic-soft/wirebox
Quick Start
use 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);
Configuration
ContainerBuilder
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.
Directory Scanning
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 }
Interface Binding
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);
Factory Registration
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'), ); });
Fluent Definition API
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 injection
Parameters and Environment Variables
Set 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)%');
Environment Variables
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.
.env File Format
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=abc123
Production: composer dump-env
For production, use Symfony's composer dump-env to generate .env.local.php:
composer dump-env prod
This creates a PHP file that returns an array β no file parsing at runtime.
PHP Attributes
#[Singleton]
Marks a class as singleton (this is the default behavior, use for explicitness):
use AsceticSoft\Wirebox\Attribute\Singleton; #[Singleton] class DatabaseConnection { }
#[Transient]
A new instance is created on every get() call:
use AsceticSoft\Wirebox\Attribute\Transient; #[Transient] class RequestContext { }
#[Lazy]
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.
Default lazy mode
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);
#[Eager]
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]
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); }
#[AutoconfigureTag]
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 {}
Programmatic Autoconfiguration
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();
CQRS Example
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 }
#[Inject]
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, ) { } }
#[Param]
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]
Exclude a class from auto-registration during directory scanning:
use AsceticSoft\Wirebox\Attribute\Exclude; #[Exclude] class InternalHelper { }
Container API
PSR-11
$service = $container->get(UserService::class); $exists = $container->has(UserService::class);
Tagged Services
$loggers = $container->getTagged('logger'); // iterable<object>
Parameters
$host = $container->getParameter('db.host'); $all = $container->getParameters();
Self-Resolution
The container registers itself, so you can type-hint it:
use Psr\Container\ContainerInterface; class ServiceLocator { public function __construct( private ContainerInterface $container, ) { } }
Compiled 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)
Circular Dependencies
Wirebox detects circular dependencies at build time (build() / compile()) and throws CircularDependencyException for unsafe cycles before the container is ever used.
When is a cycle safe?
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
When is a cycle unsafe?
| 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.
Error Handling
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(); }
Full Example
// 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();
Testing
composer install vendor/bin/phpunit
License
MIT