Lite PHP dependency injection

Wiring, without the weight.

A single-file autowire DI container for PHP and WordPress. Four methods, zero runtime dependencies, no configuration ceremony.

  • PHP 7.4–8.5
  • MIT licensed
  • PHPStan level 9
your-plugin.phpPHP
use Kama\LiteWireDI\Container;

final class Logger {
	public function log( string $message ): void {
		error_log( $message );
	}
}

final class Service {
	public function __construct(
		private Logger $logger
	) {}
}

$container = new Container();
$service = $container->get( Service::class );
Single portable PHP file
Autowiring built in
NO runtime dependencies
PSR-11 familiar API style

Four methods.
That’s it.

Register a rule when you need one. Otherwise, ask for a class and let autowiring handle the graph.

Everything in one place.

From the four-method contract to configuration patterns and a complete WordPress plugin structure.

LiteWire DI Container#

A tiny single-file autowiring DI container for PHP and WordPress.

Installation#

Install with Composer:

bash
composer require doiftrue/litewire-di

Or 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:

php
$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:

php
$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.

php
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:

php
$container->has( Service::class ); // true if registered, resolved, or autowireable
$container->has( 'Unknown' );      // false

get()#

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:

php
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.

php
$a = $container->get( Some_Service::class );
$b = $container->get( Some_Service::class );

var_dump( $a === $b ); // true

get() - Scalar values#

Required scalar values cannot be autowired. They throw ContainerException.

php
final class Config {
	public function __construct(
		private string $path
	) {}
}

$container->get( Config::class ); // ContainerException

Use make() with named parameters:

php
$config = $container->make( Config::class, [
	'path' => __DIR__ . '/config.php',
] );

Optional scalar values are supported:

php
final class Config {
	public function __construct(
		private string $path = 'config.php'
	) {}
}

$config = $container->get( Config::class ); // no error

set()#

Registers a service definition.

Service IDs must be class or interface names. Regular strings are not supported:

php
$container->set( Logger_Interface::class, File_Logger::class ); // valid
$container->set( 'logger', File_Logger::class );                // InvalidArgumentException

Accepted 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#

php
$container->set( Logger::class, $logger );
$service = $container->get( Service::class );

set() - Register an interface implementation#

php
$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.

php
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.

php
$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():

php
$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:

php
$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.

php
$a = $container->make( Some_Service::class );
$b = $container->make( Some_Service::class );

var_dump( $a === $b ); // false

This 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.

php
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:

php
$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.

php
$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.

php
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 ); // true

make() - Registered definitions#

Class-string and configured-parameter definitions are instantiated again, and closure factories are invoked on every call.

php
$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 ) ); // true

Comparison with other containers#

ContainerDepsPSR-11AutowiringShared servicesNew instance methodScalarsConfig
LiteWire DInostyleyesget()make()definition/runtimeno
PHP-DIyesyesyesget()make()yesyes
Symfony DIyesyesyesget()factories/servicesyesyes
Laravel Containeryespartly/yesyessingleton(), instance()make()yesyes
Laminas ServiceManageryesyesyesshared servicesbuild()yesyes
League ContaineryesyesyesaddShared()definitions/factoriesyesyes
Yii DI ContaineryesnoyessetSingleton()get()yesyes
Pimpleyesnonodefault behaviorfactory()yesyes
Nette DIyesno/adapteryesgenerated servicesgenerated factoriesyesyes

Best for:

  • LiteWire DI - simple PHP apps, WP plugins
  • PHP-DI - medium and large apps
  • Symfony DI - Symfony apps, compiled container
  • Laravel Container - Laravel apps
  • Laminas ServiceManager - Laminas/Mezzio apps
  • Pimple - small explicit service containers
  • League Container - framework-agnostic DI
  • Yii DI Container - Yii apps
  • Nette 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.

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
SubjectRuns × RoundsMemory peakTimeVariance
direct_instantiation10,000 × 5666.744 KB0.078 μs±2.36%
get__cold10,000 × 516.422 MB2.055 μs±1.38%
get__stored10,000 × 5666.720 KB0.060 μs±2.35%
make__reflection__cold10,000 × 515.862 MB1.986 μs±1.44%
make__reflection__cached10,000 × 5666.744 KB0.799 μs±1.57%
make__registered_factory10,000 × 5666.744 KB0.419 μs±2.68%
get__deep_autowiring__cold10,000 × 518.467 MB2.932 μs±0.86%
get__deep_autowiring__stored10,000 × 5666.744 KB0.059 μs±4.11%
has__resolvable_class__cold10,000 × 511.356 MB2.232 μs±2.92%
has__resolvable_class__stored10,000 × 5739.432 KB0.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.

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#

text
my-plugin/
├── lib/
│   └── Container.php
├── autoload.php
├── my-plugin.php
└── src/
    ├── AdminNotice.php
    ├── Plugin.php
    ├── PluginConfig.php
    ├── SettingsPage.php
    └── Logger/
        ├── Logger.php
        └── WordPressLogger.php

1. Copy the container#

Create the lib directory and copy the LiteWire DI Container.php file into it:

text
my-plugin/lib/Container.php

The 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
<?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
<?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
<?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
<?php
namespace Example\MyPlugin\Logger;

interface Logger {
	public function info( string $message ): void;
}

Create src/Logger/WordPressLogger.php:

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
<?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
<?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
<?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:

text
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:

php
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:

php
$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.

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:

php
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

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:

php
final class SomeService {
	public function __construct(
		private readonly AppConfig $config,
	) {}
}

The bootstrap file registers the configuration object:

file: bootstrap.php

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:

  • SomeService depends on the whole AppConfig object;
  • a large AppConfig can become a bag of unrelated settings.

Option 2: split config into focused objects#

If one config object grows too large, split it by responsibility:

php
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:

php
$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

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

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

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.php is 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:

php
$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

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

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

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

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

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

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

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.

Ready to wire your project?

View on GitHub