Skip to content

x3p0-dev/x3p0-framework

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

76 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

X3P0: Framework

Nova, a blue alien, as a construction worker wearing a toolbelt and holding a wrench in a city construction zone.

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.

License PHP Version

Features

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

Table of Contents

Requirements

  • PHP 8.1 or higher
  • WordPress (latest version recommended)
  • Composer

Installation

composer require x3p0-dev/x3p0-framework

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

Quick Start

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.

1. Define your services

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) {}
}

2. Register them with a service provider

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
    ];
}

3. Create your application

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
    ];
}

4. Bootstrap it

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.

Service Providers

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
    ];
}

When you need code

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(...));
}

Provider dependencies

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);
    }
}

The Container

ServiceContainer is the framework's implementation of the Container contract. Inside a provider it's available as $this->container; elsewhere, via plugin()->container().

Binding services

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

Resolving services

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

Autowiring

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

Attribute-based injection

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

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.

Lifecycle hooks

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));
});

Introspection

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

The Application

Application 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
    ];
}

Registering and booting

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.

Multiple load phases

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

Contracts

The X3P0\Framework\Contracts namespace holds small, dependency-free interfaces.

  • Bootable β€” a boot(): void method for deferred setup that shouldn't live in a constructor (registering hooks, etc.). Service providers implement it, and any abstract listed in a provider's BOOTABLE constant must too.
  • Renderable β€” a render(): string method for classes that produce escaped, safe HTML.
  • ClassRegistry β€” a registry of class names (not instances) indexed by key: register(), unregister(), isRegistered(), and get().

Exceptions

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.

License

X3P0 Framework is licensed under the GPL-2.0-or-later license.

Credits

Created and maintained by Justin Tadlock under the X3P0 umbrella.

Support

About

🧰 A lightweight, modern dependency injection framework for WordPress plugins and themes.

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Contributors

Languages