Service Container

The WPZylos Container is a lightweight PSR-11 compliant dependency injection container designed for multi-plugin isolation.

Core Concepts

Dependency Injection

Instead of creating dependencies inside classes, inject them:

// Bad: Tight coupling
class UserController
{
    public function show(): void
    {
        $db = new Database();           // Creates its own dependency
        $user = $db->find(User::class, 1);
    }
}

// Good: Dependency injection
class UserController
{
    public function __construct(private Database $db)  // Injected
    {
    }

    public function show(): void
    {
        $user = $this->db->find(User::class, 1);
    }
}

The Container's Role

The container:

  1. Knows how to create services
  2. Injects dependencies automatically
  3. Manages service lifecycles (singleton vs factory)

Basic Usage

Creating a Container

use WPZylos\Framework\Container\Container;

$container = new Container();

Binding Services

// Simple binding (new instance each time)
$container->bind(LoggerInterface::class, fn() => new FileLogger('/path/to/log'));

// Singleton (same instance every time)
$container->singleton(Database::class, fn() => new Database($config));

// Bind interface to implementation
$container->bind(CacheInterface::class, RedisCache::class);

Resolving Services

// Get a service
$logger = $container->get(LoggerInterface::class);

// Check if service exists
if ($container->has(CacheInterface::class)) {
    $cache = $container->get(CacheInterface::class);
}

Auto-Wiring

The container automatically resolves dependencies using type hints:

class OrderService
{
    public function __construct(
        private Database $db,
        private LoggerInterface $logger,
        private CacheInterface $cache
    ) {
    }
}

// Container automatically injects Database, Logger, and Cache
$service = $container->get(OrderService::class);

How Auto-Wiring Works

  1. Container inspects constructor parameters
  2. For each parameter, it resolves the type from the container
  3. Creates instance with resolved dependencies
// This happens automatically:
$db = $container->get(Database::class);
$logger = $container->get(LoggerInterface::class);
$cache = $container->get(CacheInterface::class);
$service = new OrderService($db, $logger, $cache);

Binding Patterns

Closure Binding

$container->bind(MailerInterface::class, function (Container $c) {
    return new SmtpMailer(
        $c->get(ConfigRepository::class)->get('mail.host'),
        $c->get(ConfigRepository::class)->get('mail.port')
    );
});

Class Binding

// Interface to class
$container->bind(CacheInterface::class, FileCache::class);

// Container will auto-wire FileCache's dependencies

Instance Binding

// Bind an existing instance
$container->instance(PluginContext::class, $context);

Contextual Binding

// Different implementations for different consumers
$container->when(AdminController::class)
    ->needs(LoggerInterface::class)
    ->give(AdminLogger::class);

$container->when(ApiController::class)
    ->needs(LoggerInterface::class)
    ->give(ApiLogger::class);

Singleton vs Transient

Singleton

Same instance returned every time:

$container->singleton(Database::class, fn() => new Database());

$db1 = $container->get(Database::class);
$db2 = $container->get(Database::class);

$db1 === $db2; // true

Use for:

  • Database connections
  • Configuration
  • Caches
  • Stateless services

Transient (bind)

New instance every time:

$container->bind(Request::class, fn() => Request::capture());

$req1 = $container->get(Request::class);
$req2 = $container->get(Request::class);

$req1 === $req2; // false

Use for:

  • Request/Response objects
  • Form builders
  • Stateful objects

PSR-11 Compliance

The container implements Psr\Container\ContainerInterface:

interface ContainerInterface
{
    public function get(string $id): mixed;
    public function has(string $id): bool;
}

This allows interoperability with PSR-11 compatible packages.

Service Providers

Service providers organize bindings:

<?php

namespace Vendor\MyPlugin\Providers;

use WPZylos\Framework\Core\ServiceProvider;

class DatabaseServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->container->singleton(Database::class, function () {
            return new Database([
                'prefix' => $this->context->prefix(),
            ]);
        });
    }

    public function boot(): void
    {
        // Called after all providers are registered
    }
}

Why We Did It This Way

Isolated Containers (Not Singletons)

Problem: Traditional PHP frameworks use a singleton container accessible globally. When two plugins use the same framework, they share services.

// Bad: Singleton pattern (shared state)
$container = Container::getInstance();

Solution: Each plugin creates its own Container instance.

// Good: Isolated containers
$containerA = new Container();  // Plugin A's services
$containerB = new Container();  // Plugin B's services

No Service Locator Pattern

Problem: Accessing the container from anywhere couples code to the container and hides dependencies.

// Bad: Service locator (hidden dependencies)
class UserService
{
    public function getUser(int $id): User
    {
        $db = Container::get(Database::class);  // Hidden dependency
        return $db->find(User::class, $id);
    }
}

Solution: Always inject dependencies explicitly.

// Good: Explicit dependencies
class UserService
{
    public function __construct(private Database $db)  // Visible
    {
    }

    public function getUser(int $id): User
    {
        return $this->db->find(User::class, $id);
    }
}

Lightweight Implementation

Problem: Full-featured containers like Symfony's are powerful but heavy for WordPress plugins.

Solution: WPZylos Container focuses on essential features:

  • Binding and resolving
  • Auto-wiring
  • Singletons
  • PSR-11 compliance

No compiled containers, no YAML configuration, no annotation parsing.

Advanced Usage

Method Injection

$result = $container->call([$controller, 'show'], ['id' => 123]);
// Auto-wires method parameters, merges with provided arguments

Tagged Services

$container->bind(WidgetA::class)->tag('widgets');
$container->bind(WidgetB::class)->tag('widgets');

$widgets = $container->tagged('widgets');
// Returns all services tagged with 'widgets'

Aliases

$container->alias(Database::class, 'db');

$db = $container->get('db');  // Same as get(Database::class)

Testing

Mock the container for unit tests:

public function testUserServiceFindsUser(): void
{
    $mockDb = $this->createMock(Database::class);
    $mockDb->method('find')->willReturn(new User(['id' => 1]));

    $container = new Container();
    $container->instance(Database::class, $mockDb);

    $service = $container->get(UserService::class);
    $user = $service->getUser(1);

    $this->assertSame(1, $user->id);
}

Next Steps