LiteWire DI Container#
A tiny single-file autowiring DI container for PHP and WordPress.
Installation#
Install with Composer:
composer require doiftrue/litewire-diOr copy the single Container.php file into your project. When copying it, change the Kama\LiteWireDI namespace to one owned by your project to avoid collisions with another copy of the container.
Compatibility#
PHP 7.4 and PHP 8.0-8.5 are tested in CI.
Design goals#
LiteWire DI is built for small projects, plugins, themes, and libraries where Symfony DI or PHP-DI would be too much.
It is not a replacement for large framework containers. It is for simple autowiring without config files, compiled cache, service providers, tags, scopes, scalar storage, or framework integration.
The API follows the familiar get() / has() shape:
$container->get( Service::class );
$container->has( Service::class );That keeps migration to a larger container straightforward.
It does not implement Psr\Container\ContainerInterface and does not require psr/container.
Features#
- Keep the whole container in a single PHP file.
- Use no external dependencies.
- Register existing objects, classes, closure factories, and configured constructor parameters with
set(). - Autowire registered and unregistered classes.
- Return shared service instances with
get(). - Create a new instance every time with
make(). - Pass named runtime parameters to
make(). - Check whether classes and interfaces can be resolved with
has(). - Use an object-first design with class and interface names as service IDs.
- Use default values for scalar constructor parameters.
- Use the modern Reflection API on PHP 8.
- Inject the container itself as a dependency.
- Cache Reflection data inside each container instance.
- Detect circular dependencies and show the full resolution chain.
Tradeoffs:
- Configuration goes through parameters attached to concrete class definitions, factories, runtime parameters, or config objects. There is no standalone scalar storage.
- Invokable objects can be wrapped in closures, but are not factories automatically.
- Configuration is normal PHP code. There are no attributes or DSL.
Usage Guide#
Four public methods:
has()checks if a service can be resolved.set()registers an object, class, factory, or named parameters for an instantiable class.get()returns a shared object.make()creates a fresh object.
API:
$container->has( class-string $id ): bool;
$container->set( class-string $id, object|Closure|class-string|array<string, mixed> $service ): void;
$container->get( class-string $id );
$container->make( class-string $id, array $parameters = [] );Service IDs must be class or interface names. Plain strings like logger are not supported.
Usage Example#
Logger is created automatically from the constructor type.
class Logger {
public function log( string $message ): void {
error_log( $message );
}
}
class Service {
public function __construct(
private Logger $logger
) {}
public function run(): void {
$this->logger->log( 'Service started.' );
}
}
$container = new Container();
$service = $container->get( Service::class );
$service->run();has()#
Checks whether a service is registered, already resolved, or autowireable.
For an unregistered class, the full constructor graph must be valid.
Usage:
$container->has( Service::class ); // true if registered, resolved, or autowireable
$container->has( 'Unknown' ); // falseget()#
Returns a shared service instance.
If it was already created, the same object is returned.
get() - Autowiring#
get() can create an unregistered class from constructor types:
class Logger {
public function write( string $message ): void {
error_log( $message );
}
}
class Report_Service {
private $logger;
public function __construct( Logger $logger ) {
$this->logger = $logger;
}
public function generate(): void {
$this->logger->write( 'Report generated.' );
}
}
$service = $container->get( Report_Service::class );
$service->generate();Neither Report_Service nor Logger needs registration. The container builds the graph automatically.
get() - Shared services#
get() stores the resolved service. The next call returns the same instance.
$a = $container->get( Some_Service::class );
$b = $container->get( Some_Service::class );
var_dump( $a === $b ); // trueget() - Scalar values#
Required scalar values cannot be autowired. They throw ContainerException.
final class Config {
public function __construct(
private string $path
) {}
}
$container->get( Config::class ); // ContainerExceptionUse make() with named parameters:
$config = $container->make( Config::class, [
'path' => __DIR__ . '/config.php',
] );Optional scalar values are supported:
final class Config {
public function __construct(
private string $path = 'config.php'
) {}
}
$config = $container->get( Config::class ); // no errorset()#
Registers a service definition.
Service IDs must be class or interface names. Regular strings are not supported:
$container->set( Logger_Interface::class, File_Logger::class ); // valid
$container->set( 'logger', File_Logger::class ); // InvalidArgumentExceptionAccepted values:
- existing object -
new MyClass() - existing class name -
MyClass::class - closure factory -
static fn () => new MyClass() - named parameters for an instantiable class -
[ 'parameter_name' => $value ]
set() - Register an existing object#
$container->set( Logger::class, $logger );
$service = $container->get( Service::class );set() - Register an interface implementation#
$container->set( Logger_Interface::class, File_Logger::class );
$logger = $container->get( Logger_Interface::class );set() - Configure constructor parameters#
An associative array registers persistent named constructor parameters for the instantiable class used as the service ID. The container autowires any omitted class dependencies.
final class Plugin {
private string $main_file;
private Options $options;
public function __construct(
string $main_file,
Options $options
) {
$this->main_file = $main_file;
$this->options = $options;
}
}
$container->set( Plugin::class, [
'main_file' => __FILE__,
] );
$plugin = $container->get( Plugin::class );The configured object is shared like any other result returned by get(). Parameter names are checked when the service is resolved; unknown names throw ContainerException.
This form only works when the service ID is an instantiable class. For an interface, use a class-string binding when the implementation needs no scalar configuration; otherwise use a factory.
set() - Register a factory#
Factories must return an object.
$container->set( Client::class, static function () {
return new Client( 'https://example.com' );
} );
$client = $container->get( Client::class );set() - Factory autowiring#
Factory parameters are autowired for both get() and make():
$container->set( Mailer::class, static function ( Logger $logger ) {
return new Mailer( $logger );
} );
$shared_mailer = $container->get( Mailer::class );
$fresh_mailer = $container->make( Mailer::class );Type-hint Container in a factory parameter to receive the container:
$container->set( Plugin::class, static function ( Container $container ) {
return new Plugin( $container->get( Config::class ) );
} );
$plugin = $container->get( Plugin::class );make()#
Creates a fresh object from a registered definition or class name.
Unlike get(), it does not store the object.
make() - New instances#
make() creates a new object without saving it.
$a = $container->make( Some_Service::class );
$b = $container->make( Some_Service::class );
var_dump( $a === $b ); // falseThis is useful for stateful objects, DTOs, handlers, commands, forms, and other short-lived objects.
make() - Runtime parameters#
The second argument of make() is an array keyed by constructor parameter name. Provided values go straight to the constructor. Missing class dependencies are autowired.
Unknown parameter names throw ContainerException.
This makes make() useful for objects that mix services with runtime values. In tests, the factory call can be replaced with a mock.
class Mailer {
public function __construct(
private Logger $logger,
private string $from
) {}
}
$mailer = $container->make( Mailer::class, [
'from' => 'admin@example.com',
] );If set() registered configured parameters for the class, make() uses them as defaults and creates a fresh instance. Parameters passed directly to make() take priority:
$container->set( Mailer::class, [
'from' => 'default@example.com',
] );
$mailer = $container->make( Mailer::class, [
'from' => 'override@example.com',
] );make() - Existing object definitions#
Definitions registered as existing object instances cannot be used with make(). Use get() to retrieve those instances.
$logger = new Logger();
$container->set( Logger::class, $logger );
$same_logger = $container->get( Logger::class ); // OK.
$new_logger = $container->make( Logger::class ); // Throws ContainerException.make() - Shared dependencies#
Only the requested root object is created anew. Class dependencies are resolved with get(), so they are shared and reused by subsequent calls.
class ReportController {
public function __construct( public Logger $logger ) {}
}
$first = $container->make( ReportController::class );
$second = $container->make( ReportController::class );
var_dump( $first === $second ); // false
var_dump( $first->logger === $second->logger ); // truemake() - Registered definitions#
Class-string and configured-parameter definitions are instantiated again, and closure factories are invoked on every call.
$container->set( Mailer::class, static fn () => new Mailer() );
var_dump( $container->make( Mailer::class ) === $container->make( Mailer::class ) ); // false
var_dump( $container->get( Mailer::class ) === $container->get( Mailer::class ) ); // trueComparison with other containers#
| Container | Deps | PSR-11 | Autowiring | Shared services | New instance method | Scalars | Config |
|---|---|---|---|---|---|---|---|
| LiteWire DI | no | style | yes | get() | make() | definition/runtime | no |
| PHP-DI | yes | yes | yes | get() | make() | yes | yes |
| Symfony DI | yes | yes | yes | get() | factories/services | yes | yes |
| Laravel Container | yes | partly/yes | yes | singleton(), instance() | make() | yes | yes |
| Laminas ServiceManager | yes | yes | yes | shared services | build() | yes | yes |
| League Container | yes | yes | yes | addShared() | definitions/factories | yes | yes |
| Yii DI Container | yes | no | yes | setSingleton() | get() | yes | yes |
| Pimple | yes | no | no | default behavior | factory() | yes | yes |
| Nette DI | yes | no/adapter | yes | generated services | generated factories | yes | yes |
Best for:
LiteWire DI- simple PHP apps, WP pluginsPHP-DI- medium and large appsSymfony DI- Symfony apps, compiled containerLaravel Container- Laravel appsLaminas ServiceManager- Laminas/Mezzio appsPimple- small explicit service containersLeague Container- framework-agnostic DIYii DI Container- Yii appsNette DI- Nette apps
Limitations#
LiteWire DI does not include:
- A compiled container.
- Complex configuration files.
- Attributes or a special configuration language (DSL).
- A debug mode.
- Arbitrary string service IDs.
- Invokable objects used directly as factories.
- Service definitions passed through the container constructor.
- Service providers.
- Scopes.
- Tags.
- Scalar parameter storage.
- Union or intersection type resolution.
- Variadic parameter resolution.
Required scalar constructor parameters must be provided by your code through configured class parameters, a factory, make(), or a configuration object.
These features would make the API larger. LiteWire DI keeps one strict object model in one small PHP file.
Full PSR-11 support would require psr/container. LiteWire DI keeps the API style without the dependency. An optional adapter may be added later.
Report
Benchmarks#
View Markdown source ↗Latest detailed performance measurements for LiteWire DI. Timings depend on PHP, hardware, extensions, and system load, so compare runs from the same environment.
Latest run#
- Date: 2026-07-06
- PHP: 8.5.5
- PHPBench: 1.7.0
- OPcache: enabled
- Xdebug: disabled
- Subjects: 8
- Failures and errors: 0
| Subject | Runs × Rounds | Memory peak | Time | Variance |
|---|---|---|---|---|
direct_instantiation | 10,000 × 5 | 666.744 KB | 0.078 μs | ±2.36% |
get__cold | 10,000 × 5 | 16.422 MB | 2.055 μs | ±1.38% |
get__stored | 10,000 × 5 | 666.720 KB | 0.060 μs | ±2.35% |
make__reflection__cold | 10,000 × 5 | 15.862 MB | 1.986 μs | ±1.44% |
make__reflection__cached | 10,000 × 5 | 666.744 KB | 0.799 μs | ±1.57% |
make__registered_factory | 10,000 × 5 | 666.744 KB | 0.419 μs | ±2.68% |
get__deep_autowiring__cold | 10,000 × 5 | 18.467 MB | 2.932 μs | ±0.86% |
get__deep_autowiring__stored | 10,000 × 5 | 666.744 KB | 0.059 μs | ±4.11% |
has__resolvable_class__cold | 10,000 × 5 | 11.356 MB | 2.232 μs | ±2.92% |
has__resolvable_class__stored | 10,000 × 5 | 739.432 KB | 0.120 μs | ±2.64% |
Column legend#
- Subject — the operation measured by PHPBench.
- Runs — the number of times the subject is executed during each round (
@Revs(10000)). - Rounds — the number of independently measured iterations (
@Iterations(5)). - Memory peak — the peak memory used by the complete benchmark process while measuring that subject. It is not the per-call allocation.
- Time — the modal execution time for one subject call. One microsecond (
μs) is 0.001 milliseconds. - Variance — relative standard deviation between measured iterations. Lower values indicate a more stable result.
Subjects#
- direct_instantiation
Creates ClassWithDeps and SimpleClass directly with new. This is the baseline without container work.
- get__cold
Creates a new container and resolves ClassWithDeps for the first time. The measurement includes reflection, dependency discovery, object construction, and storage of the shared instance.
- get__stored
Returns a previously resolved shared ClassWithDeps instance. This measures the container's stored-instance lookup path.
- make__reflection__cold
Creates a new container and calls make() before reflection metadata has been cached. The result is not stored as a shared service.
- make__reflection__cached
Calls make() after the same container has already resolved the class once. This measures fresh object creation with cached reflection metadata.
- make__registered_factory
Creates ClassWithDeps through a registered closure factory. It measures factory invocation and container dispatch without reflection-based construction.
- get__deep_autowiring__cold
Creates a new container and resolves the three-level ClassDeepA → ClassDeepB → ClassDeepC graph for the first time.
- get__deep_autowiring__stored
Returns a previously resolved ClassDeepA graph. This measures lookup of an already stored top-level shared instance.
Extended guide
WordPress plugin#
View Markdown source ↗This example shows a small plugin with an admin settings page and an admin notice. It uses a container to connect the plugin classes.
The example is intentionally simple. It works as a useful starting structure, not as a complete production plugin.
Why use a container in a plugin?#
A WordPress plugin often starts with functions in one file. Later it gets admin pages, REST routes, repositories, API clients, and background jobs. Creating every object by hand then becomes repetitive.
The container helps with this object setup:
- constructors clearly show what each class needs;
- concrete classes are connected automatically;
- interface choices and scalar configuration stay in one bootstrap file;
- shared objects are reused;
- tests can create a class with fake dependencies without loading WordPress.
The container should be used during plugin startup. Do not pass it into every class. Normal classes should ask for their real dependencies in the constructor.
Project structure#
my-plugin/
├── lib/
│ └── Container.php
├── autoload.php
├── my-plugin.php
└── src/
├── AdminNotice.php
├── Plugin.php
├── PluginConfig.php
├── SettingsPage.php
└── Logger/
├── Logger.php
└── WordPressLogger.php1. Copy the container#
Create the lib directory and copy the LiteWire DI Container.php file into it:
my-plugin/lib/Container.phpThe plugin now owns its copy of the container. Users do not need to install LiteWire DI or run Composer on their WordPress site.
When you update LiteWire DI, replace this file with the new version and test the plugin. Keep the license and source information at the top of the file.
2. Main plugin file#
Create my-plugin.php. This file loads the copied container and the plugin autoloader, configures the container, and starts the plugin.
<?php
/**
* Plugin Name: LiteWire DI Example
* Description: A small plugin built with constructor injection.
* Requires PHP: 8.1
* Version: 1.0.0
*/
namespace Example\MyPlugin;
defined( 'ABSPATH' ) || exit;
require_once __DIR__ . '/lib/Container.php';
require_once __DIR__ . '/autoload.php';
add_action( 'plugins_loaded', 'example_my_plugin_init' );
function example_my_plugin_init(): void {
$container = new \Kama\LiteWireDI\Container();
// The container cannot guess which class should implement an interface.
$container->set( Logger::class, WordPressLogger::class );
// The container cannot guess scalar values such as file paths and slugs.
$container->set(
PluginConfig::class,
new PluginConfig( [
'plugin_file' => __FILE__,
'option_name' => 'lwdi_message',
'menu_slug' => 'litewire-di-example',
] )
);
// Plugin and its remaining dependencies are created automatically.
$container->get( Plugin::class )->boot();
}This is the composition root: the one place where the application is assembled. Interface bindings and project values belong here.
Configure everything before calling get(). get() stores shared objects, so changing a definition later will not update objects which were already created.
3. Autoloader#
This example does not use Composer. The small autoloader maps the Example\MyPlugin namespace to the src directory.
Create autoload.php:
<?php
namespace Example\MyPlugin;
spl_autoload_register( static function ( string $class ): void {
if ( ! str_starts_with( $class, __NAMESPACE__ . '\\' ) ) {
return;
}
$path = str_replace( [ __NAMESPACE__, '\\' ], [ __DIR__ . '/src', '/' ], $class );
require_once "$path.php";
} );4. Plugin configuration object#
Create src/PluginConfig.php:
<?php
namespace Example\MyPlugin;
final class PluginConfig {
public readonly string $plugin_file;
public readonly string $option_name;
public readonly string $menu_slug;
public function __construct( array $config ) {
$this->plugin_file = $config['plugin_file'];
$this->option_name = $config['option_name'];
$this->menu_slug = $config['menu_slug'];
}
}A configuration object is better when the same related values are used by several classes. It gives the values clear names and keeps them type-safe. For scalar values used by only one concrete class, set( MyClass::class, [ 'parameter' => $value ] ) is a shorter alternative.
5. Logger interface and implementation#
Create src/Logger/Logger.php:
<?php
namespace Example\MyPlugin\Logger;
interface Logger {
public function info( string $message ): void;
}Create src/Logger/WordPressLogger.php:
<?php
namespace Example\MyPlugin\Logger;
final class WordPressLogger implements Logger {
public function info( string $message ): void {
error_log( '[LiteWire DI Example] ' . $message );
}
}The plugin classes depend on Logger, not on WordPressLogger. Production uses the WordPress implementation. A test can pass a small fake logger.
6. Settings page#
Create src/SettingsPage.php:
<?php
namespace Example\MyPlugin;
use Example\MyPlugin\Logger\Logger;
final class SettingsPage {
public function __construct(
private readonly PluginConfig $config,
private readonly Logger $logger
) {
}
public function register_hooks(): void {
add_action( 'admin_menu', [ $this, 'add_page' ] );
add_action( 'admin_init', [ $this, 'register_setting' ] );
$basename = plugin_basename( $this->config->plugin_file );
add_filter( "plugin_action_links_$basename", [ $this, 'add_action_link' ] );
}
public function add_action_link( array $links ): array {
$url = admin_url( "options-general.php?page={$this->config->menu_slug}" );
array_unshift( $links, '<a href="' . esc_url( $url ) . '">Settings</a>' );
return $links;
}
public function add_page(): void {
add_options_page(
'Example message',
'Example message',
'manage_options',
$this->config->menu_slug,
[ $this, 'render' ]
);
}
public function register_setting(): void {
register_setting(
$this->config->menu_slug,
$this->config->option_name,
[ 'sanitize_callback' => [ $this, 'sanitize' ] ]
);
}
public function sanitize( $value ): string {
$message = sanitize_text_field( (string) $value );
$this->logger->info( 'The admin message was updated.' );
return $message;
}
public function render(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$value = get_option( $this->config->option_name, '' );
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<form action="options.php" method="post">
<?php settings_fields( $this->config->menu_slug ); ?>
<label for="lwdi-message">Message</label>
<input
id="lwdi-message"
class="regular-text"
name="<?php echo esc_attr( $this->config->option_name ); ?>"
value="<?php echo esc_attr( (string) $value ); ?>"
>
<?php submit_button(); ?>
</form>
</div>
<?php
}
}SettingsPage asks for PluginConfig and Logger. The bootstrap registered both. LiteWire DI passes them into the constructor.
7. Admin notice#
Create src/AdminNotice.php:
<?php
namespace Example\MyPlugin;
final class AdminNotice {
public function __construct(
private readonly PluginConfig $config
) {
}
public function register_hooks(): void {
add_action( 'admin_notices', [ $this, 'render' ] );
}
public function render(): void {
$message = get_option( $this->config->option_name, '' );
if ( ! is_string( $message ) || $message === '' ) {
return;
}
?>
<div class="notice notice-info">
<p><?php echo esc_html( $message ); ?></p>
</div>
<?php
}
}AdminNotice uses the same shared PluginConfig object as SettingsPage.
8. Plugin controller#
Create src/Plugin.php:
<?php
namespace Example\MyPlugin;
final class Plugin {
public function __construct(
private readonly SettingsPage $settings_page,
private readonly AdminNotice $admin_notice
) {
}
public function boot(): void {
$this->settings_page->register_hooks();
$this->admin_notice->register_hooks();
}
}The bootstrap only asks for Plugin. LiteWire DI follows the constructors and builds this graph:
Plugin
├── SettingsPage
│ ├── PluginConfig
│ └── Logger → WordPressLogger
└── AdminNotice
└── PluginConfig (same shared object)How to add another service#
For a concrete class, add it to a constructor. No bootstrap change is needed:
public function __construct(
private readonly PluginConfig $config,
private readonly MessageRepository $messages
) {
}Register something only when LiteWire DI cannot decide how to create it:
- bind an interface to a concrete class with
set(); - register an existing configured object;
- attach named scalar parameters to a concrete class with
set(); - use a factory when construction needs custom logic or a third-party API.
Testing a class without the container#
The container is object setup, not a requirement inside your classes. A unit test can create SettingsPage directly:
$config = new PluginConfig( [
'plugin_file' => '/tmp/my-plugin.php',
'option_name' => 'test-option',
'menu_slug' => 'test-menu',
] );
$logger = new FakeLogger();
$page = new SettingsPage( $config, $logger );This is the main reason to keep dependencies in constructors: they are easy to see and easy to replace in tests.
Extended guide
Configuration#
View Markdown source ↗Configuration is a set of values that changes how an application works without changing its code.
Configuration helps to:
- keep changeable settings separate from application logic;
- use different values in production, development, and tests;
- avoid global variables and direct
getenv()calls inside services.
Each container handles configuration differently. The examples below show the usual approach for each container.
All examples use PHP 8.2 syntax.
Service used in all examples#
Most examples below create this service:
final class SomeService {
public function __construct(
private readonly string $db_host,
private readonly int $db_port,
private readonly string $db_name,
private readonly string $api_url,
private readonly int $api_timeout,
private readonly string $cache_dir,
private readonly bool $debug,
) {}
}LiteWire DI#
LiteWire DI can only store objects, not individual config values. That keeps the container small, but configuration remains application code.
There are several reasonable ways to connect configuration values to services.
Option 1: inject one config object#
This is usually the simplest LiteWire DI option. The application creates one readonly configuration object, registers it in the container, and lets autowiring pass it to services.
file: config.php
final readonly class AppConfig {
public function __construct(
public string $db_host,
public int $db_port,
public string $db_name,
public string $api_url,
public int $api_timeout,
public string $cache_dir,
public bool $debug,
) {}
}
return new AppConfig(
db_host: 'localhost',
db_port: 3306,
db_name: 'my_application',
api_url: 'https://api.example.com',
api_timeout: 10,
cache_dir: __DIR__ . '/var/cache',
debug: true,
);The service asks for the config object instead of many scalar values:
final class SomeService {
public function __construct(
private readonly AppConfig $config,
) {}
}The bootstrap file registers the configuration object:
file: bootstrap.php
$container = new Container();
$config = require __DIR__ . '/config.php';
$container->set( AppConfig::class, $config );
$some_service = $container->get( SomeService::class );Pros:
- smallest bootstrap code;
- no factory is needed for
SomeService; - the config object is typed and readonly.
Cons:
SomeServicedepends on the wholeAppConfigobject;- a large
AppConfigcan become a bag of unrelated settings.
Option 2: split config into focused objects#
If one config object grows too large, split it by responsibility:
final readonly class DatabaseConfig {
public function __construct(
public string $host,
public int $port,
public string $name,
) {}
}
final readonly class ApiConfig {
public function __construct(
public string $url,
public int $timeout,
) {}
}
final class SomeService {
public function __construct(
private readonly DatabaseConfig $database,
private readonly ApiConfig $api,
) {}
}Register each configured object:
$container = new Container();
$container->set( DatabaseConfig::class, new DatabaseConfig(
host: 'localhost',
port: 3306,
name: 'my_application',
) );
$container->set( ApiConfig::class, new ApiConfig(
url: 'https://api.example.com',
timeout: 10,
) );
$some_service = $container->get( SomeService::class );Pros:
- services depend only on the configuration they use;
- each config object has a clear purpose;
- large applications stay easier to navigate.
Cons:
- more classes and registrations;
- too many tiny config objects can add noise in a small application.
Option 3: configure named constructor parameters#
For an instantiable service class with scalar constructor arguments, config.php can return an associative parameter array that is passed directly to set(). The keys must match constructor parameter names. Any omitted class dependencies are autowired.
file: config.php
return [
'db_host' => 'localhost',
'db_port' => 3306,
'db_name' => 'my_application',
'api_url' => 'https://api.example.com',
'api_timeout' => 10,
'cache_dir' => __DIR__ . '/var/cache',
'debug' => true,
];file: bootstrap.php
$container = new Container();
$values = require __DIR__ . '/config.php';
$container->set( SomeService::class, $values );
$some_service = $container->get( SomeService::class );Pros:
- the config file stays familiar while bootstrap code remains small;
- no extra config class or factory is required;
- remaining object dependencies are still autowired;
make()can create a fresh instance and override individual configured values.
Cons:
- parameter names and value types are checked at runtime;
- the config file is coupled to this class's constructor signature;
- values are local to this class definition and are not reusable scalar entries;
- an interface whose implementation needs scalar configuration requires a factory because the array does not identify that implementation.
Option 4: create a typed config object from an array#
If the application already has an array config file but services should depend on a typed configuration object, map the loaded values into that object in bootstrap code.
file: bootstrap.php
$container = new Container();
$values = require __DIR__ . '/config.php';
$container->set( AppConfig::class, new AppConfig(
db_host: $values['db_host'],
db_port: $values['db_port'],
db_name: $values['db_name'],
api_url: $values['api_url'],
api_timeout: $values['api_timeout'],
cache_dir: $values['cache_dir'],
debug: $values['debug'],
) );
$some_service = $container->get( SomeService::class );Pros:
config.phpis familiar and easy to override;- values can be loaded, merged, or validated before objects are created;
- services receive a reusable typed config object.
Cons:
- array keys are not checked by PHP;
- bootstrap code has to map array values into the object;
- type errors are discovered later than with direct object creation.
Option 5: use a factory for scalar constructor values#
If a service should keep scalar constructor arguments, register a factory for that service:
$container = new Container();
$container->set( SomeService::class, static function () {
return new SomeService(
db_host: 'localhost',
db_port: 3306,
db_name: 'my_application',
api_url: 'https://api.example.com',
api_timeout: 10,
cache_dir: __DIR__ . '/var/cache',
debug: true,
);
} );
$some_service = $container->get( SomeService::class );Pros:
- the service constructor stays explicit;
- no extra config class is required;
- useful for one-off services or third-party classes.
Cons:
- repeated scalar values can spread across factories;
- configuration is less reusable in tests and other services;
- large factories become harder to read.
For small applications, option 1 is usually enough. If the config object becomes too broad, option 2 is the cleaner next step. Option 3 is the most concise choice when an array config belongs to one concrete class. Use option 4 when services should receive a reusable typed config object, and option 5 when construction needs custom logic or an interface implementation must be selected at the same time.
PHP-DI#
PHP-DI can store values under string IDs such as database.host and app.debug. It can pass those values directly to the service constructor.
The PHP-DI definition file contains both the values and instructions that connect each value to a constructor argument:
file: config.php
use function DI\autowire;
use function DI\get;
return [
'database.host' => 'localhost',
'database.port' => 3306,
'database.name' => 'my_application',
'api.url' => 'https://api.example.com',
'api.timeout' => 10,
'cache.directory' => __DIR__ . '/var/cache',
'app.debug' => true,
SomeService::class => autowire()
->constructorParameter( 'db_host', get( 'database.host' ) )
->constructorParameter( 'db_port', get( 'database.port' ) )
->constructorParameter( 'db_name', get( 'database.name' ) )
->constructorParameter( 'api_url', get( 'api.url' ) )
->constructorParameter( 'api_timeout', get( 'api.timeout' ) )
->constructorParameter( 'cache_dir', get( 'cache.directory' ) )
->constructorParameter( 'debug', get( 'app.debug' ) ),
];file: bootstrap.php
$builder = new DI\ContainerBuilder();
$builder->addDefinitions( __DIR__ . '/config.php' );
$container = $builder->build();
$some_service = $container->get( SomeService::class );PHP-DI resolves every get() reference and passes the corresponding value to SomeService. The container owns both the scalar values and the rules for injecting them.
Symfony DI#
Symfony stores scalar values as container parameters. A service can reference those parameters in services.yaml:
file: config/services.yaml
parameters:
database.host: 'localhost'
database.port: 3306
database.name: 'my_application'
api.url: 'https://api.example.com'
api.timeout: 10
cache.directory: '%kernel.project_dir%/var/cache'
app.debug: true
services:
SomeService:
public: true
arguments:
$db_host: '%database.host%'
$db_port: '%database.port%'
$db_name: '%database.name%'
$api_url: '%api.url%'
$api_timeout: '%api.timeout%'
$cache_dir: '%cache.directory%'
$debug: '%app.debug%'file: bootstrap.php
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
$container = new ContainerBuilder();
$container->setParameter( 'kernel.project_dir', __DIR__ );
$loader = new YamlFileLoader(
$container,
new FileLocator( __DIR__ . '/config' )
);
$loader->load( 'services.yaml' );
$container->compile();
$some_service = $container->get( SomeService::class );Laravel Container#
Laravel stores application settings in files inside the config directory:
file: config/some_service.php
return [
'db_host' => 'localhost',
'db_port' => 3306,
'db_name' => 'my_application',
'api_url' => 'https://api.example.com',
'api_timeout' => 10,
'cache_dir' => storage_path( 'framework/cache' ),
'debug' => true,
];A service provider uses contextual binding to connect each constructor argument to a configuration value:
file: app/Providers/SomeServiceProvider.php
use Illuminate\Support\ServiceProvider;
final class SomeServiceProvider extends ServiceProvider {
public function register(): void {
$this->app->when( SomeService::class )
->needs( '$db_host' )
->giveConfig( 'some_service.db_host' );
$this->app->when( SomeService::class )
->needs( '$db_port' )
->giveConfig( 'some_service.db_port' );
$this->app->when( SomeService::class )
->needs( '$db_name' )
->giveConfig( 'some_service.db_name' );
$this->app->when( SomeService::class )
->needs( '$api_url' )
->giveConfig( 'some_service.api_url' );
$this->app->when( SomeService::class )
->needs( '$api_timeout' )
->giveConfig( 'some_service.api_timeout' );
$this->app->when( SomeService::class )
->needs( '$cache_dir' )
->giveConfig( 'some_service.cache_dir' );
$this->app->when( SomeService::class )
->needs( '$debug' )
->giveConfig( 'some_service.debug' );
}
}The bootstrap code creates the application container, loads the configuration, and registers the provider:
file: bootstrap.php
use Illuminate\Config\Repository;
use Illuminate\Foundation\Application;
$app = new Application( __DIR__ );
$app->instance( 'config', new Repository( [
'some_service' => require __DIR__ . '/config/some_service.php',
] ) );
$app->register( SomeServiceProvider::class );
$some_service = $app->make( SomeService::class );$app->register() creates the provider and calls its register() method. Laravel then reads the configured values and injects them when $app->make() resolves SomeService.
Are these approaches equivalent?#
They create the same SomeService with the same settings. PHP-DI can store individual values and inject them by reference. LiteWire DI either stores typed configuration objects or attaches literal named values directly to one concrete class definition.
The container features are also not equivalent. PHP-DI stores scalar entries and can reference, combine, and override definitions. LiteWire DI does not expose scalar entries: its configured parameter values belong only to their class definition. Loading, combining, or validating the original values remains application code.
For a small application, one AppConfig object is often enough. If it becomes too large, split it into focused objects such as DatabaseConfig, ApiConfig, and CacheConfig and register each object in the same way.