A lightweight, modern dependency injection framework for WordPress plugins and themes. Built with PHP 8.1+, it provides a robust DI container and abstract application layer to help you write cleaner, more maintainable WordPress code.
- Autowiring container β resolves constructor dependencies by type, including union and intersection types.
- Declarative service providers β describe bindings, aliases, tags, and bootables with simple class constants; drop to code only when you need it.
- Attribute-driven injection β
#[Get],#[Defer],#[Tagged],#[DeferredTagged], and#[Singleton]configure resolution right at the point of use. - Flexible lifetimes β singletons, transients, pre-built instances, aliases, and "register only if missing" defaults that extensions can override.
- Tagging β group related services under a label and resolve them together, eagerly or lazily.
- Lifecycle hooks β observe (
resolving()) or wrap (decorate()) services as they are built. - WordPress-friendly lifecycle β register and boot across multiple load phases (
plugins_loaded,after_setup_theme, β¦) while the core stays free of WordPress dependencies. - Type-safe β full PHP 8.1+ type declarations for first-class IDE and static-analysis support.
- Requirements
- Installation
- Quick Start
- Service Providers
- The Container
- The Application
- Contracts
- Exceptions
- License
- PHP 8.1 or higher
- WordPress (latest version recommended)
- Composer
composer require x3p0-dev/x3p0-frameworkDistributing a plugin or theme? Vendor-prefix your dependencies with a tool like PHP-Scoper so your copy of the framework can't collide with another plugin's.
The framework leans on declarative configuration: you describe what your providers contribute using class constants, list those providers on your application, and decide when registration and booting happen.
Write plain classes. Constructor dependencies are autowired, so you rarely wire anything by hand.
namespace Your\Project;
interface Cache {}
final class FileCache implements Cache {}
final class ReportBuilder
{
// The container injects a Cache implementation automatically.
public function __construct(private readonly Cache $cache) {}
}Prefer the declarative constants β SINGLETONS, TRANSIENTS, ALIASES, TAGS, BOOTABLE β over imperative calls. The base register() and boot() handle them for you.
namespace Your\Project;
use X3P0\Framework\Core\ServiceProvider;
final class CacheServiceProvider extends ServiceProvider
{
// Bind an interface to a concrete, shared across the request.
protected const SINGLETONS = [
Cache::class => FileCache::class
];
// Resolve `'cache'` to the same binding as `Cache::class`.
protected const ALIASES = [
'cache' => Cache::class
];
}List your providers on the PROVIDERS constant. They're registered when the application is constructed.
namespace Your\Project;
use X3P0\Framework\Core\Application;
final class Plugin extends Application
{
protected const PROVIDERS = [
CacheServiceProvider::class
];
}The framework fires no hooks of its own β you choose when to register and boot. A typical plugin instantiates the application, fires a registration hook so third parties can add providers, then boots:
namespace Your\Project;
use X3P0\Framework\Container\ServiceContainer;
require_once __DIR__ . '/vendor/autoload.php';
function plugin(): Plugin
{
static $plugin;
return $plugin ??= new Plugin(new ServiceContainer());
}
add_action('plugins_loaded', static function (): void {
do_action('your/project/register', plugin());
plugin()->boot();
}, -999);That's the whole loop. The rest of this document covers what each piece can do.
A service provider is the home for a slice of your project's wiring. Extend ServiceProvider and describe its contributions with constants. You only override register() or boot() when a binding needs real logic (a closure factory, a conditional, etc.).
use X3P0\Framework\Core\ServiceProvider;
final class BlockServiceProvider extends ServiceProvider
{
// Shared instances. A bare value is self-bound (the class is its own
// concrete); a key => value pair binds an abstract to a concrete.
protected const SINGLETONS = [
BlockRegistry::class,
Renderer::class => HtmlRenderer::class
];
// New instance on every resolution. Same key conventions as SINGLETONS.
protected const TRANSIENTS = [
RequestContext::class
];
// Overridable defaults: registered only if the abstract isn't already
// bound, so an extension can replace them regardless of load order.
protected const SINGLETONS_IF = [
Logger::class => NullLogger::class
];
protected const TRANSIENTS_IF = [
View::class => PhpView::class
];
// Alias => abstract. Resolving the alias resolves the abstract.
protected const ALIASES = [
'blocks' => BlockRegistry::class
];
// Tag => list of abstracts, resolvable together via `tagged()`.
protected const TAGS = [
'theme.blocks' => [AlertBlock::class, CalloutBlock::class]
];
// Services resolved and booted during the provider's boot phase. Each
// must implement `Bootable`. They boot in the order listed.
protected const BOOTABLE = [
BlockRegistrar::class
];
}Override register() for bindings that need a closure or runtime decisions, and call parent::register() to keep the constant-driven bindings:
public function register(): void
{
parent::register();
$this->container->singleton(Connection::class, function (Container $c): Connection {
return new Connection($c->get(Config::class)->dsn());
});
}Override boot() the same way (calling parent::boot()) when you need to do more than boot the BOOTABLE services β for example, hooking into WordPress:
public function boot(): void
{
parent::boot();
add_action('init', $this->registerBlocks(...));
}Providers given by class name are resolved through the container, so they can type-hint their own dependencies. Accept the Container, pass it to the parent, and add whatever else you need:
use X3P0\Framework\Container\Container;
use X3P0\Framework\Core\ServiceProvider;
final class ReportServiceProvider extends ServiceProvider
{
public function __construct(Container $container, private readonly Clock $clock)
{
parent::__construct($container);
}
}ServiceContainer is the framework's implementation of the Container contract. Inside a provider it's available as $this->container; elsewhere, via plugin()->container().
// Shared instance, built once and reused.
$container->singleton(Cache::class, FileCache::class);
// New instance on every resolution.
$container->transient(RequestContext::class);
// A value the container stores and returns as-is (never built or autowired).
$container->instance('config', new Config([...]));
// Alias one identifier to another (followed transitively).
$container->alias('cache', Cache::class);The *If variants register a binding only if the identifier isn't already bound, which makes them ideal for defaults an extension may override regardless of load order:
$container->singletonIf(Logger::class, NullLogger::class);
$container->transientIf(RequestContext::class);Re-binding an identifier with singleton()/transient() replaces any existing binding and clears its cached instance, so the replacement takes effect on the next resolution.
// Resolve by identifier.
$cache = $container->get(Cache::class);
// Resolve a class, optionally overriding constructor parameters by name.
$report = $container->make(ReportBuilder::class, ['format' => 'pdf']);
// Invoke a callable with its parameters resolved from the container.
$result = $container->call([$controller, 'handle']);
$result = $container->call(SomeController::class . '::handle');
// Get a closure that resolves the service on each call β without the
// consumer needing the container itself.
$makeReport = $container->defer(ReportBuilder::class);
$report = $makeReport();A parameterized make() (one given overrides) is never cached.
When the container builds a class, it resolves each constructor parameter from its type β including union and intersection types, falling back to a default value or null when a parameter allows it. Most classes need no binding at all:
final class ReportBuilder
{
public function __construct(
private readonly Cache $cache, // resolved by type
private readonly int $limit = 50, // default used if not provided
) {}
}
$container->make(ReportBuilder::class);Mark a class #[Singleton] to have the container share a single instance whenever it autowires the class, without an explicit binding:
use X3P0\Framework\Container\Attributes\Singleton;
#[Singleton]
final class FileCache implements Cache {}Note: values that can't be constructed β enums, interfaces, and abstract classes β can't be autowired. Provide them with an explicit binding, a
make()override, or an attribute (below).
Parameter attributes configure how a single dependency is resolved, right where it's declared.
use X3P0\Framework\Container\Attributes\Get;
use X3P0\Framework\Container\Attributes\Defer;
use X3P0\Framework\Container\Attributes\Tagged;
use X3P0\Framework\Container\Attributes\DeferredTagged;
final class Dashboard
{
public function __construct(
// Resolve a specific identifier (a keyed binding or a chosen concrete).
#[Get('config')] private readonly Config $config,
// Inject a closure that resolves the service lazily, on demand.
#[Defer(ReportBuilder::class)] private readonly Closure $makeReport,
// Inject every service assigned to a tag, already resolved.
#[Tagged('theme.blocks')] private readonly iterable $blocks,
// Inject the tagged services as deferred resolvers, keyed by class,
// so you build only the ones you actually use.
#[DeferredTagged('report.sections')] private readonly array $sections
) {}
}| Attribute | Target | Injects |
|---|---|---|
#[Get($id)] |
parameter | the result of get($id) |
#[Defer($id)] |
parameter | a Closure that resolves $id on each call |
#[Tagged($tag)] |
parameter | an array of the tag's resolved services |
#[DeferredTagged($tag)] |
parameter | array<class-string, Closure> of deferred resolvers, keyed by abstract |
#[Singleton] |
class | opts an autowired class into a shared lifetime |
You can build your own by implementing ContextualAttribute:
use X3P0\Framework\Container\Attributes\ContextualAttribute;
use X3P0\Framework\Container\Container;
#[Attribute(Attribute::TARGET_PARAMETER)]
final class CurrentUser implements ContextualAttribute
{
public function resolve(Container $container): object
{
return $container->get(UserRepository::class)->current();
}
}Tagging groups related abstracts under a label so they can be resolved together β blocks, widgets, REST controllers, CLI commands, and the like β without maintaining a master list by hand.
$container->tag([AlertBlock::class, CalloutBlock::class], 'theme.blocks');
foreach ($container->tagged('theme.blocks') as $block) {
$block->register();
}Tagged abstracts resolve through the container like anything else, so singletons stay shared and unbound classes are autowired. An unknown tag resolves to an empty array.
| Method | Returns |
|---|---|
tag($abstracts, $tag) |
β assigns one or more abstracts to a tag |
untag($abstracts, $tag) |
β removes abstracts from a tag |
tagged($tag) |
the tag's services, resolved |
taggedAbstracts($tag) |
the tag's abstracts, without resolving them |
hasTag($tag) |
whether any abstracts are currently assigned |
Because tags accumulate, several providers β or third-party code hooking your registration action β can contribute to the same tag without touching the provider that consumes it:
add_action('your/project/register', static function ($app): void {
$app->container()->singleton(TestimonialBlock::class);
$app->container()->tag(TestimonialBlock::class, 'theme.blocks');
});For large or expensive collections, pair a tag with #[DeferredTagged] so consumers receive per-service resolver closures (keyed by class name) and build only what they need.
Observe or transform services as they're built.
// Run after the service is built, to mutate it in place. Runs once per build,
// so a singleton is observed only the first time it's created.
$container->resolving(ReportBuilder::class, function (object $builder, Container $c): void {
$builder->setTimezone($c->get(Config::class)->timezone());
});
// Wrap or replace the service with something honoring the same contract.
// Decorators stack in registration order. If the service is already
// resolved, the decorator is applied to the stored instance immediately.
$container->decorate(Cache::class, function (Cache $cache, Container $c): Cache {
return new LoggingCache($cache, $c->get(Logger::class));
});$container->has(Cache::class); // resolvable? (bound or an autowirable class)
$container->registered(Cache::class); // explicitly bound or instance-registered?
$container->resolved(Cache::class); // already built and cached?
$container->forgetInstance(Cache::class); // drop the cached instance; rebuild next timeApplication is the hub that registers and boots your service providers. Subclass it, list providers on PROVIDERS, and drive the lifecycle from your plugin or theme.
final class Plugin extends Application
{
protected const PROVIDERS = [
CoreServiceProvider::class,
AdminServiceProvider::class,
FrontendServiceProvider::class
];
}register() is variadic and accepts provider instances or class names. Class names are resolved through the container, so providers can declare their own dependencies.
$app->register(AdminServiceProvider::class, RestServiceProvider::class);boot() boots every registered-but-unbooted provider and is safe to call repeatedly β each provider boots only once. Once the application has booted, a provider registered afterward boots immediately, so nothing registered late is left dormant. A batch passed to register() is registered in full before any of it boots.
To register across more than one WordPress phase, call begin() to open each pass. It clears the booted state (so that pass's providers register as a batch before booting) and returns the application, ready to hand to a registration hook:
add_action('plugins_loaded', static function (): void {
do_action('your/project/register/plugin', plugin()->begin());
plugin()->boot();
}, -999);
add_action('after_setup_theme', static function (): void {
do_action('your/project/register/theme', plugin()->begin());
plugin()->boot();
}, -999);A single register-then-boot pass doesn't need begin(); it's required only to open each additional pass (and harmless on the first).
The X3P0\Framework\Contracts namespace holds small, dependency-free interfaces.
Bootableβ aboot(): voidmethod for deferred setup that shouldn't live in a constructor (registering hooks, etc.). Service providers implement it, and any abstract listed in a provider'sBOOTABLEconstant must too.Renderableβ arender(): stringmethod for classes that produce escaped, safe HTML.ClassRegistryβ a registry of class names (not instances) indexed by key:register(),unregister(),isRegistered(), andget().
All container failures surface as X3P0\Framework\Container\ContainerException; an unknown identifier throws NotFoundException (a subtype, so catching the base covers both). On the application side, InvalidProviderException is thrown when a registered class isn't a ServiceProvider, and UnbootableServiceException when a BOOTABLE entry doesn't implement Bootable. Both extend ApplicationException.
X3P0 Framework is licensed under the GPL-2.0-or-later license.
Created and maintained by Justin Tadlock under the X3P0 umbrella.