wpdi – A WordPress-Native DI Container

TL;DR: Most DI containers let you inject anything – objects, strings, closures – which requires manual wiring and makes it easy to accidentally turn the container into a caching or configuration layer. wpdi enforces a single constraint: everything is a typed object. That unlocks zero-config autowiring, IDE-navigable dependencies, and static analysis of the full dependency graph.

I’ve used dependency injection in WordPress plugins for years. Constructor injection, interface segregation, composition roots – the principles aren’t new. The tooling is where I kept getting stuck.

Every DI container I’ve used in the WordPress ecosystem required manual wiring. Register this class. Bind that interface. Map this string to that factory closure. For a plugin with 300 classes, that’s a maintenance nightmare nobody signs up for. The containers support everything – objects, strings, closures, arrays – and that flexibility is exactly what makes it drift.

In a development ecosystem where most developers haven’t worked with DI before, a flexible container quietly turns into something it shouldn’t be. Resolve get_option() at boot time and inject the result? The container doesn’t complain, the workflow looks correct. But the value is now frozen for the entire request. Inject every config flag as a string? Now the container is a configuration registry. These aren’t wrong according to the container’s API. They’re all valid uses. That’s the problem.

I think the fix isn’t more discipline or better documentation, because I’ve tried both and watched the same patterns eventually re-emerge. What works for me: a constraint that makes the wrong thing structurally impossible. That’s what I built with wpdi – a DI container for WordPress plugins with one deliberate limitation: everything is a typed object. No strings, no scalars, no closures.

What follows: the constraint, the design decisions it unlocks, and a playground repo to try it in five minutes.

Flexibility Is the Problem

Insight: A flexible container doesn’t prevent misuse – it makes misuse indistinguishable from correct usage.

Most DI containers treat all values equally. A service can be a class instance, a string, a boolean, or a closure. That’s fine in frameworks where DI is table stakes and every developer on the team knows the conventions. WordPress is different.

DI isn’t a common pattern in the WordPress ecosystem. When you hand someone a container that accepts anything, the container will eventually hold everything. I’ve seen this happen across multiple production plugins, and the patterns are consistent:

The caching trap. A developer registers get_option( 'api_key' ) as a service. The container resolves it once during bootstrap and injects the string everywhere. The value is now frozen for the entire request. If the option changes – via settings page, API call, or filter – nobody notices until production.

The configuration registry. Feature flags, API endpoints, version strings, debug booleans – they all end up as container entries. The container is no longer managing object dependencies. It’s a key-value store with extra steps.

The invisible dependency. String-keyed service IDs like $container->get( 'payment_flow' ) work at runtime but are invisible to the IDE. You can’t Cmd+click to the definition, you can’t find all consumers without grepping, and renaming is a gamble. The dependency graph only exists in documentation that may or may not be current.

None of these are bugs. The container’s API permits all of them. I think that’s the core problem, because a tool that can’t distinguish correct usage from misuse can’t help you maintain the boundary.

One Constraint: Everything Is Typed

Context: This isn’t “no primitives allowed in your code” – it’s “the container only manages object dependencies. Services own their own config.”

wpdi enforces a single rule: every service and every service argument must be a typed class or interface. Strings, integers, booleans, arrays, and closures are rejected at resolution time.

This eliminates the ambiguity that forces manual wiring. When every dependency is identified by its class or interface name, the container can resolve the entire graph automatically. There’s no question of “which string did you mean?” – there’s only one Cache_Interface, one Payment_Gateway, one Invoice_Prefix.

The config file reflects this. Here’s the full wpdi-config.php from a demo plugin with 10+ services. It only needs entries to resolve an interface and an abstract base class:

<?php

use WpdiDemo\Interfaces\RandomizerInterface;
use WpdiDemo\Interfaces\RegistryBase;
use WpdiDemo\Services\Randomizer;
use WpdiDemo\Services\FortyTwo;
use WpdiDemo\Services\ClassRegistry;
use WpdiDemo\Services\CityRegistry;

return [
	// Resolve interfaces.
	RandomizerInterface::class => [
		'$randomizer' => Randomizer::class,
		'$forty_two'  => FortyTwo::class,
	],
	// Resolve abstract base classes.
	RegistryBase::class => [
		'$class_registry' => ClassRegistry::class,
		'$city_registry'  => CityRegistry::class,
	],
];

That’s it. Two interfaces with multiple implementations each, disambiguated by parameter name. When a constructor asks for RandomizerInterface $randomizer, the container injects Randomizer. When it asks for RandomizerInterface $forty_two, it gets FortyTwo. This is deliberate – the parameter name becomes a project-wide convention. Every class that uses $randomizer gets the same implementation. That makes the codebase predictable and grep-friendly. Concrete classes with a unique type resolve automatically – no entry needed.

Every dependency is a ::class reference. Cmd+clickable. Refactorable. Analyzable by static tools. wp di compile validates the entire graph at build time. Errors surface before deployment, not as a runtime NotFoundException on a Tuesday afternoon.

Autowiring as Instant Win

Insight: Static dependency analysis – seeing the full wiring without reading a single file – is a consequence of type safety, not a separate feature.

The direct payoff of type-only injection is that the container can resolve everything by reading constructor signatures. No services file. No registration calls. No manual wiring.

Here’s a service from the demo plugin with four dependencies:

class CharacterFactory {
	public function __construct(
		private readonly CityRegistry $city_registry,
		private readonly ClassRegistry $class_registry,
		private readonly FateEngine $fate_engine,
		private readonly NameGenerator $name_generator,
	) {
	}

	// ...
}

wpdi sees four typed parameters, resolves each one (including their own dependencies), and injects them. CharacterFactory never appears in the config file. Neither do CityRegistry, FateEngine, or NameGenerator. The container discovers them by scanning the src/ directory using PHP tokenization – no class loading, no side effects.

This also unlocks something I didn’t initially design for: static dependency analysis. Because the entire graph is typed and deterministic, WP-CLI commands can inspect it without executing any plugin code:

Sample output of the "wp di inspect" command in a terminal window. The output shows the full dependency tree of the class named "Runner"

The full wiring, visible without reading a single file. wp di depends RandomizerInterface goes the other direction – showing every class that consumes that interface.

In development, auto-discovery rebuilds on every request. For production, wp di compile pre-builds the cache. If the graph has unresolvable dependencies, they surface here – not after deployment.

Services Own Their Config

Gotcha: If you’re reaching for a scalar in a constructor, ask: should this be a service that provides the value? Usually, yes.

When the container can’t hold primitives, services must own their own configuration. This sounds like a workaround, but it’s not – it’s the mechanism that prevents the container from becoming a junk drawer.

Instead of injecting get_option( 'invoice_prefix' ) as a string, wrap it in a thin typed class:

class Invoice_Prefix {
	public function value(): string {
		return get_option( 'invoice_prefix' );
	}
}

Invoice_Prefix is injectable, mockable, and self-documenting. More importantly, it reads the option when called – not when the container boots. No stale values.

I think this is the part where the constraint earns its keep, because it doesn’t just prevent misuse. It guides you toward a design where each service is responsible for its own runtime behavior. The container manages construction, everything after that is the service’s job.

The Container Disappears After Bootstrap

Insight: A container that persists after bootstrap is a service locator waiting to happen. wpdi discards it structurally.

The container exists during one phase: bootstrap. After that, it’s gone.

Here’s the full plugin entry point:

// plugin.php
require_once __DIR__ . '/vendor/autoload.php';

DemoApp::boot( __FILE__ );

And the composition root:

class DemoApp extends Scope {
	protected function bootstrap( Resolver $resolver ): void {
		if ( defined( 'WP_CLI' ) && \WP_CLI ) {
			$resolver->get( CensusCommand::class )->register();
		}

		add_action(
			'rest_api_init',
			static fn() => $resolver->get( CensusEndpoint::class )->register()
		);
	}
}

bootstrap() receives a Resolver – a read-only view with get() and has(), no bind(). You resolve your entry-point services, register hooks, and return. After bootstrap() completes, the container is discarded. No reference is stored anywhere.

This makes the service locator pattern structurally impossible. There’s no app()->make() you can call later. Every dependency must be declared in the constructor, resolved at boot time, and held as a direct reference. If a class needs something, it says so in its signature. No hidden runtime lookups.

A side effect: hooks can’t be registered in constructors. That’s a known anti-pattern in WordPress plugin architecture – it conflates construction with registration and makes classes impossible to instantiate in tests. wpdi prevents it by default, not by convention.

Try It in Five Minutes

I’ve set up a playground repo that puts all of this in a working WordPress plugin. The setup is three commands:

git clone https://github.com/stracker-phil/wpdi-playground.git
cd wpdi-playground/demo-plugin && composer install
cd .. && ddev start

The demo plugin generates fictional 1789 census records. The dependency chain is non-trivial: Runner depends on CharacterFactory, which depends on four leaf services that each declare their own typed dependencies. All of it autowired from constructor signatures alone.

# Generate a census record
ddev wp census --name=Marie

# Explore the container
ddev wp di list
ddev wp di inspect Runner
ddev wp di compile

ddev wp di inspect Runner shows the full dependency tree. ddev wp di list shows every registered service. The code is intentionally simple – the architecture is the point, not the business logic.

Scope and Limits

wpdi is designed for plugin-internal dependency graphs. It doesn’t replace WordPress hooks for cross-plugin communication, and it won’t help with inter-plugin service sharing. Every service is a singleton – if you need per-request state, wrap it in a typed value object rather than fighting the container. The 18 ADRs in the repo document every constraint and the reasoning behind it. If you prefer to explore interactively, the repo is connected to DeepWiki – you can browse the architecture or ask questions about specific design decisions directly.

That said, a single constraint – everything is typed – unlocks autowiring, eliminates manual wiring, and makes the dependency graph statically analyzable. If you want to evaluate the approach, clone the playground repo and run ddev wp di inspect Runner. Then look at wpdi-config.php – that’s all the configuration there is.