Back to Blog
Development Patterns

Building Enterprise WordPress Plugin Architecture: Service Containers, Dependency Injection, and SOLID Principles

Priya Sharma
40 min read

The Problem With How Most WordPress Plugins Are Built

Most WordPress plugins are built the same way. A developer creates a main plugin file, dumps a handful of functions into the global namespace, wires them directly to hooks with add_action and add_filter, and calls it done. For a 200-line plugin that adds a shortcode, this works fine. For anything larger, it becomes a trap.

Consider a typical WordPress plugin that manages custom post types, interacts with an external API, sends emails, and provides admin settings. Built the “WordPress way,” you end up with something like this:

// my-plugin.php
function myplugin_init() {
    register_post_type('portfolio', [
        'public' => true,
        'label'  => 'Portfolio',
    ]);
}
add_action('init', 'myplugin_init');

function myplugin_fetch_api_data($post_id) {
    $api_key = get_option('myplugin_api_key');
    $response = wp_remote_get("https://api.example.com/data?key={$api_key}");
    // Process response, update post meta...
}

function myplugin_send_notification($post_id) {
    $to = get_option('myplugin_admin_email');
    $subject = 'New Portfolio Item';
    wp_mail($to, $subject, 'A new portfolio item was published.');
}
add_action('publish_portfolio', 'myplugin_send_notification');

function myplugin_settings_page() {
    // 150 lines of settings HTML and processing...
}
add_action('admin_menu', function() {
    add_options_page('My Plugin', 'My Plugin', 'manage_options', 'myplugin', 'myplugin_settings_page');
});

This code has several structural problems. Every function lives in the global namespace, so naming collisions are one careless function fetch_data() away. The API key is fetched directly from the database inside business logic, making it impossible to test myplugin_fetch_api_data without a running WordPress installation. The email function is tightly coupled to wp_mail, so you cannot swap it for a transactional email service without rewriting the function. And the settings page mixes presentation, validation, and persistence into one blob.

The singleton pattern, commonly recommended as an improvement, solves the namespace problem but introduces new ones:

class MyPlugin {
    private static $instance = null;

    public static function get_instance() {
        if (null === self::$instance) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct() {
        add_action('init', [$this, 'register_post_types']);
        add_action('publish_portfolio', [$this, 'send_notification']);
    }

    public function register_post_types() { /* ... */ }
    public function send_notification($post_id) {
        // Still directly calling wp_mail, get_option, wp_remote_get...
    }
}

The singleton gives you a class, but not architecture. The constructor still wires everything directly. Dependencies are still hidden inside method bodies. Testing is still impossible without WordPress loaded. You have traded global functions for a global object, which is not meaningfully different.

What enterprise PHP applications figured out years ago is that these problems have well-established solutions: dependency injection, service containers, and adherence to the SOLID principles. This article shows how to apply those patterns to WordPress plugin development with real, working PHP code.

PSR-4 Autoloading With Composer

Before building any architecture, you need a way to organize and load classes automatically. WordPress’s require_once chains are brittle and manual. Composer’s PSR-4 autoloading maps namespaces to directories, so when you reference Jenga\PostType\PortfolioPostType, PHP knows to look in src/PostType/PortfolioPostType.php.

Start with a composer.json in your plugin root:

{
    "name": "your-vendor/jenga",
    "description": "Enterprise WordPress plugin with DI container",
    "autoload": {
        "psr-4": {
            "Jenga\\": "src/"
        }
    },
    "require": {
        "php": ">=8.0",
        "php-di/php-di": "^7.0"
    },
    "require-dev": {
        "phpunit/phpunit": "^10.0",
        "mockery/mockery": "^1.6",
        "brain/monkey": "^2.6"
    }
}

Run composer install to generate the autoloader. Your plugin’s main file then only needs one require:

<?php
/**
 * Plugin Name: Jenga
 * Description: Portfolio manager with enterprise architecture
 * Version: 1.0.0
 */

if (!defined('ABSPATH')) {
    exit;
}

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

// Bootstrap the plugin (we'll build this next)
\Jenga\Plugin::boot(__FILE__);

Organize your src/ directory to reflect the domain:

jenga/
├── jenga.php              # Main plugin file
├── composer.json
├── src/
│   ├── Plugin.php         # Bootstrap class
│   ├── Container.php      # Service container configuration
│   ├── Contract/          # Interfaces
│   │   ├── MailerInterface.php
│   │   ├── PortfolioRepositoryInterface.php
│   │   └── ApiClientInterface.php
│   ├── PostType/
│   │   └── PortfolioPostType.php
│   ├── Repository/
│   │   └── PortfolioRepository.php
│   ├── Service/
│   │   ├── NotificationService.php
│   │   ├── ApiSyncService.php
│   │   └── SettingsService.php
│   ├── Mailer/
│   │   ├── WpMailer.php
│   │   └── SmtpMailer.php
│   ├── Hook/
│   │   └── HookManager.php
│   └── Event/
│       ├── PortfolioPublishedEvent.php
│       └── PortfolioUpdatedEvent.php
├── tests/
│   ├── Unit/
│   │   ├── Service/
│   │   │   └── NotificationServiceTest.php
│   │   └── Repository/
│   │       └── PortfolioRepositoryTest.php
│   └── bootstrap.php
└── vendor/

Each class lives in exactly one file. Each file declares exactly one class. The namespace matches the directory path. This is not merely a convention; PSR-4 requires it for autoloading to work.

Implementing a Service Container

A service container (also called an IoC container, or inversion of control container) is a registry that knows how to build objects and their dependencies. Instead of manually creating objects with new NotificationService(new WpMailer(), new PortfolioRepository()) in every place you need one, you ask the container for a NotificationService and it handles construction, including resolving all nested dependencies.

Why Not Just Use “new”?

Using new directly creates a rigid dependency chain. If NotificationService requires a MailerInterface, and you write new NotificationService(new WpMailer()) inside a hook callback, then every place that creates a NotificationService must know about WpMailer. When you switch to SmtpMailer, you hunt through the codebase updating every instantiation point. A container centralizes this wiring in one place.

Using PHP-DI

PHP-DI is a mature, well-documented container that supports autowiring (automatic resolution of constructor parameters based on type hints). Here is how to configure it for the plugin:

<?php

namespace Jenga;

use DI\ContainerBuilder;
use Psr\Container\ContainerInterface;
use Jenga\Contract\MailerInterface;
use Jenga\Contract\PortfolioRepositoryInterface;
use Jenga\Contract\ApiClientInterface;
use Jenga\Mailer\WpMailer;
use Jenga\Repository\PortfolioRepository;
use Jenga\Service\ApiSyncService;
use Jenga\Service\NotificationService;
use Jenga\Service\SettingsService;
use Jenga\PostType\PortfolioPostType;
use Jenga\Hook\HookManager;

class Container
{
    public static function build(string $pluginFile): ContainerInterface
    {
        $builder = new ContainerBuilder();

        $builder->addDefinitions([
            // Scalar values
            'plugin.file' => $pluginFile,
            'plugin.path' => plugin_dir_path($pluginFile),
            'plugin.url'  => plugin_dir_url($pluginFile),

            // Interface bindings
            MailerInterface::class => \DI\autowire(WpMailer::class),
            PortfolioRepositoryInterface::class => \DI\autowire(PortfolioRepository::class),
            ApiClientInterface::class => \DI\autowire(ApiSyncService::class),

            // Services with specific configuration
            SettingsService::class => \DI\autowire()
                ->constructorParameter('optionPrefix', 'jenga_'),

            NotificationService::class => \DI\autowire()
                ->constructorParameter('adminEmail', \DI\factory(function () {
                    return get_option('jenga_admin_email', get_option('admin_email'));
                })),

            // Post types
            PortfolioPostType::class => \DI\autowire(),

            // Hook manager
            HookManager::class => \DI\autowire(),
        ]);

        return $builder->build();
    }
}

The key decisions here: interfaces are bound to concrete implementations in one location, scalar configuration values are injected as named parameters, and factory closures handle values that come from the WordPress database.

Building a Minimal Container From Scratch

If you prefer not to pull in a dependency, you can build a lightweight container in about 80 lines. This is useful for understanding what containers actually do:

<?php

namespace Jenga;

use Closure;
use ReflectionClass;
use ReflectionNamedType;
use RuntimeException;

class SimpleContainer
{
    private array $bindings = [];
    private array $instances = [];

    public function bind(string $abstract, Closure|string $concrete): void
    {
        $this->bindings[$abstract] = $concrete;
    }

    public function singleton(string $abstract, Closure|string $concrete): void
    {
        $this->bindings[$abstract] = $concrete;
        $this->instances[$abstract] = null; // Mark as singleton
    }

    public function get(string $abstract): object
    {
        // Return cached singleton if available
        if (array_key_exists($abstract, $this->instances) && $this->instances[$abstract] !== null) {
            return $this->instances[$abstract];
        }

        // Resolve from binding or attempt autowiring
        if (isset($this->bindings[$abstract])) {
            $concrete = $this->bindings[$abstract];
            $object = $concrete instanceof Closure
                ? $concrete($this)
                : $this->resolve($concrete);
        } else {
            $object = $this->resolve($abstract);
        }

        // Cache singletons
        if (array_key_exists($abstract, $this->instances)) {
            $this->instances[$abstract] = $object;
        }

        return $object;
    }

    public function has(string $abstract): bool
    {
        return isset($this->bindings[$abstract]) || class_exists($abstract);
    }

    private function resolve(string $class): object
    {
        $reflector = new ReflectionClass($class);

        if (!$reflector->isInstantiable()) {
            throw new RuntimeException("Class {$class} is not instantiable.");
        }

        $constructor = $reflector->getConstructor();

        if ($constructor === null) {
            return new $class();
        }

        $dependencies = [];

        foreach ($constructor->getParameters() as $param) {
            $type = $param->getType();

            if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
                $dependencies[] = $this->get($type->getName());
            } elseif ($param->isDefaultValueAvailable()) {
                $dependencies[] = $param->getDefaultValue();
            } else {
                throw new RuntimeException(
                    "Cannot resolve parameter \${$param->getName()} for {$class}."
                );
            }
        }

        return $reflector->newInstanceArgs($dependencies);
    }
}

This container supports three operations: binding an interface to an implementation, marking a binding as a singleton (created once, reused thereafter), and autowiring (reading constructor type hints to resolve dependencies automatically). The resolve method uses PHP’s Reflection API to inspect a class’s constructor, determine what it needs, and recursively resolve each dependency.

Usage:

$container = new SimpleContainer();
$container->bind(MailerInterface::class, WpMailer::class);
$container->bind(PortfolioRepositoryInterface::class, PortfolioRepository::class);
$container->singleton(SettingsService::class, SettingsService::class);

$notifier = $container->get(NotificationService::class);
// NotificationService receives a WpMailer automatically via autowiring

Constructor Injection for WordPress Hooks

The standard WordPress pattern of calling add_action inside a constructor is an anti-pattern for testable code. The constructor runs side effects (registering hooks), which means you cannot instantiate the class in a test without triggering WordPress hook registration. Constructor injection separates “what this class needs” from “when it gets wired into WordPress.”

The HookManager Pattern

Create a dedicated class that takes services from the container and wires them to WordPress hooks:

<?php

namespace Jenga\Hook;

use Jenga\PostType\PortfolioPostType;
use Jenga\Service\NotificationService;
use Jenga\Service\ApiSyncService;
use Jenga\Service\SettingsService;

class HookManager
{
    public function __construct(
        private PortfolioPostType $portfolioPostType,
        private NotificationService $notificationService,
        private ApiSyncService $apiSyncService,
        private SettingsService $settingsService,
    ) {}

    public function register(): void
    {
        // Post types
        add_action('init', [$this->portfolioPostType, 'register']);

        // Notifications
        add_action('transition_post_status', [$this->notificationService, 'onStatusChange'], 10, 3);

        // API sync
        add_action('save_post_portfolio', [$this->apiSyncService, 'syncOnSave'], 10, 2);

        // Admin
        add_action('admin_menu', [$this->settingsService, 'registerMenuPage']);
        add_action('admin_init', [$this->settingsService, 'registerSettings']);

        // Filters
        add_filter('the_content', [$this->portfolioPostType, 'appendPortfolioMeta']);
    }
}

Now the bootstrap class ties it together:

<?php

namespace Jenga;

class Plugin
{
    public static function boot(string $pluginFile): void
    {
        $container = Container::build($pluginFile);

        /** @var Hook\HookManager $hooks */
        $hooks = $container->get(Hook\HookManager::class);
        $hooks->register();
    }
}

This design has clear benefits. Each service class has a clean constructor that declares its dependencies via type hints. No service class knows about add_action or add_filter. The HookManager is the only place where WordPress wiring happens. If you want to test NotificationService, you construct it with mock dependencies and call its methods directly, no WordPress required.

Service Classes With Pure Constructors

Each service receives what it needs through its constructor:

<?php

namespace Jenga\Service;

use Jenga\Contract\MailerInterface;
use Jenga\Contract\PortfolioRepositoryInterface;

class NotificationService
{
    public function __construct(
        private MailerInterface $mailer,
        private PortfolioRepositoryInterface $repository,
        private string $adminEmail = '',
    ) {}

    public function onStatusChange(string $newStatus, string $oldStatus, \WP_Post $post): void
    {
        if ($post->post_type !== 'portfolio') {
            return;
        }

        if ($newStatus === 'publish' && $oldStatus !== 'publish') {
            $this->notifyAdminOfPublication($post);
        }
    }

    private function notifyAdminOfPublication(\WP_Post $post): void
    {
        if (empty($this->adminEmail)) {
            return;
        }

        $portfolioItem = $this->repository->findById($post->ID);

        $this->mailer->send(
            $this->adminEmail,
            "New Portfolio Item Published: {$post->post_title}",
            $this->buildEmailBody($portfolioItem)
        );
    }

    private function buildEmailBody(array $item): string
    {
        return sprintf(
            "A new portfolio item has been published.\n\nTitle: %s\nClient: %s\nURL: %s",
            $item['title'],
            $item['client'] ?? 'N/A',
            $item['url'] ?? '#'
        );
    }
}

Notice that NotificationService never calls wp_mail directly, never calls get_option, and never calls WP_Query. It depends only on the interfaces it declares. This is the essence of dependency injection: a class asks for what it needs rather than reaching out to get it.

The Repository Pattern for WP_Query Abstraction

WP_Query is one of the most heavily used classes in WordPress, and also one of the hardest to test against. It requires a database, loaded WordPress core, and a populated wp_posts table. The repository pattern puts a boundary between your business logic and the query layer.

Define the Interface

<?php

namespace Jenga\Contract;

interface PortfolioRepositoryInterface
{
    public function findById(int $id): ?array;
    public function findAll(int $limit = 10, int $offset = 0): array;
    public function findByClient(string $client): array;
    public function findPublished(int $limit = 10): array;
    public function save(array $data): int;
    public function delete(int $id): bool;
    public function count(array $criteria = []): int;
}

Implement Against WP_Query

<?php

namespace Jenga\Repository;

use Jenga\Contract\PortfolioRepositoryInterface;
use WP_Query;

class PortfolioRepository implements PortfolioRepositoryInterface
{
    private const POST_TYPE = 'portfolio';

    public function findById(int $id): ?array
    {
        $post = get_post($id);

        if (!$post || $post->post_type !== self::POST_TYPE) {
            return null;
        }

        return $this->hydrate($post);
    }

    public function findAll(int $limit = 10, int $offset = 0): array
    {
        $query = new WP_Query([
            'post_type'      => self::POST_TYPE,
            'posts_per_page' => $limit,
            'offset'         => $offset,
            'post_status'    => 'any',
            'orderby'        => 'date',
            'order'          => 'DESC',
        ]);

        return array_map([$this, 'hydrate'], $query->posts);
    }

    public function findByClient(string $client): array
    {
        $query = new WP_Query([
            'post_type'      => self::POST_TYPE,
            'posts_per_page' => -1,
            'meta_query'     => [
                [
                    'key'   => '_portfolio_client',
                    'value' => $client,
                ],
            ],
        ]);

        return array_map([$this, 'hydrate'], $query->posts);
    }

    public function findPublished(int $limit = 10): array
    {
        $query = new WP_Query([
            'post_type'      => self::POST_TYPE,
            'posts_per_page' => $limit,
            'post_status'    => 'publish',
        ]);

        return array_map([$this, 'hydrate'], $query->posts);
    }

    public function save(array $data): int
    {
        $postData = [
            'post_type'    => self::POST_TYPE,
            'post_title'   => $data['title'] ?? '',
            'post_content' => $data['description'] ?? '',
            'post_status'  => $data['status'] ?? 'draft',
        ];

        if (isset($data['id']) && $data['id'] > 0) {
            $postData['ID'] = $data['id'];
            wp_update_post($postData);
            $postId = $data['id'];
        } else {
            $postId = wp_insert_post($postData);
        }

        if (is_wp_error($postId)) {
            return 0;
        }

        // Save meta fields
        $metaFields = ['client', 'project_url', 'technologies', 'completion_date'];
        foreach ($metaFields as $field) {
            if (isset($data[$field])) {
                update_post_meta($postId, "_portfolio_{$field}", $data[$field]);
            }
        }

        return $postId;
    }

    public function delete(int $id): bool
    {
        $post = get_post($id);

        if (!$post || $post->post_type !== self::POST_TYPE) {
            return false;
        }

        return wp_delete_post($id, true) !== false;
    }

    public function count(array $criteria = []): int
    {
        $args = [
            'post_type'      => self::POST_TYPE,
            'posts_per_page' => 1,
            'fields'         => 'ids',
        ];

        if (isset($criteria['status'])) {
            $args['post_status'] = $criteria['status'];
        }

        if (isset($criteria['client'])) {
            $args['meta_query'] = [
                ['key' => '_portfolio_client', 'value' => $criteria['client']],
            ];
        }

        $query = new WP_Query($args);
        return $query->found_posts;
    }

    private function hydrate(\WP_Post $post): array
    {
        return [
            'id'              => $post->ID,
            'title'           => $post->post_title,
            'description'     => $post->post_content,
            'status'          => $post->post_status,
            'client'          => get_post_meta($post->ID, '_portfolio_client', true),
            'project_url'     => get_post_meta($post->ID, '_portfolio_project_url', true),
            'technologies'    => get_post_meta($post->ID, '_portfolio_technologies', true),
            'completion_date' => get_post_meta($post->ID, '_portfolio_completion_date', true),
            'created_at'      => $post->post_date,
            'updated_at'      => $post->post_modified,
        ];
    }
}

The hydrate method transforms a WP_Post object into a plain array. This is a deliberate choice. Passing WP_Post objects around ties your entire codebase to WordPress’s internal representation. Plain arrays (or value objects, if you prefer stronger typing) keep your domain logic portable.

Why This Matters for Testing

Your tests can now work against the interface:

$mockRepo = Mockery::mock(PortfolioRepositoryInterface::class);
$mockRepo->shouldReceive('findById')
    ->with(42)
    ->andReturn([
        'id' => 42,
        'title' => 'Test Project',
        'client' => 'Acme Corp',
        'url' => 'https://example.com',
    ]);

$service = new NotificationService($mockMailer, $mockRepo, '[email protected]');

No database. No WordPress. The test runs in milliseconds.

The Strategy Pattern for Swappable Implementations

The strategy pattern lets you define a family of algorithms, encapsulate each one, and make them interchangeable. In a WordPress plugin, this is extremely useful for things like email delivery, caching backends, image processing, and payment gateways.

The Mailer Example

Start with the interface (the “strategy” contract):

<?php

namespace Jenga\Contract;

interface MailerInterface
{
    public function send(string $to, string $subject, string $body, array $headers = []): bool;
    public function sendHtml(string $to, string $subject, string $htmlBody, array $headers = []): bool;
}

Implement two strategies:

<?php

namespace Jenga\Mailer;

use Jenga\Contract\MailerInterface;

class WpMailer implements MailerInterface
{
    public function send(string $to, string $subject, string $body, array $headers = []): bool
    {
        return wp_mail($to, $subject, $body, $headers);
    }

    public function sendHtml(string $to, string $subject, string $htmlBody, array $headers = []): bool
    {
        $headers[] = 'Content-Type: text/html; charset=UTF-8';
        return wp_mail($to, $subject, $htmlBody, $headers);
    }
}
<?php

namespace Jenga\Mailer;

use Jenga\Contract\MailerInterface;

class SmtpMailer implements MailerInterface
{
    public function __construct(
        private string $host,
        private int $port,
        private string $username,
        private string $password,
        private string $encryption = 'tls',
    ) {}

    public function send(string $to, string $subject, string $body, array $headers = []): bool
    {
        // PHPMailer-based SMTP implementation
        $phpmailer = new \PHPMailer\PHPMailer\PHPMailer(true);

        try {
            $phpmailer->isSMTP();
            $phpmailer->Host       = $this->host;
            $phpmailer->SMTPAuth   = true;
            $phpmailer->Username   = $this->username;
            $phpmailer->Password   = $this->password;
            $phpmailer->SMTPSecure = $this->encryption;
            $phpmailer->Port       = $this->port;

            $phpmailer->setFrom($this->username);
            $phpmailer->addAddress($to);
            $phpmailer->Subject = $subject;
            $phpmailer->Body    = $body;

            return $phpmailer->send();
        } catch (\Exception $e) {
            error_log("SmtpMailer error: {$e->getMessage()}");
            return false;
        }
    }

    public function sendHtml(string $to, string $subject, string $htmlBody, array $headers = []): bool
    {
        $phpmailer = new \PHPMailer\PHPMailer\PHPMailer(true);

        try {
            $phpmailer->isSMTP();
            $phpmailer->Host       = $this->host;
            $phpmailer->SMTPAuth   = true;
            $phpmailer->Username   = $this->username;
            $phpmailer->Password   = $this->password;
            $phpmailer->SMTPSecure = $this->encryption;
            $phpmailer->Port       = $this->port;

            $phpmailer->setFrom($this->username);
            $phpmailer->addAddress($to);
            $phpmailer->Subject = $subject;
            $phpmailer->isHTML(true);
            $phpmailer->Body    = $htmlBody;

            return $phpmailer->send();
        } catch (\Exception $e) {
            error_log("SmtpMailer error: {$e->getMessage()}");
            return false;
        }
    }
}

Switching from wp_mail to SMTP is now a one-line change in the container configuration:

// In Container::build()
MailerInterface::class => \DI\factory(function () {
    $method = get_option('jenga_mail_method', 'wp_mail');

    if ($method === 'smtp') {
        return new SmtpMailer(
            get_option('jenga_smtp_host', ''),
            (int) get_option('jenga_smtp_port', 587),
            get_option('jenga_smtp_user', ''),
            get_option('jenga_smtp_pass', ''),
        );
    }

    return new WpMailer();
}),

No other class in the entire plugin changes. The NotificationService still type-hints MailerInterface, so it works with either implementation transparently.

Extending the Strategy Pattern to Caching

The same approach applies to caching. Define an interface:

<?php

namespace Jenga\Contract;

interface CacheInterface
{
    public function get(string $key, mixed $default = null): mixed;
    public function set(string $key, mixed $value, int $ttl = 3600): bool;
    public function delete(string $key): bool;
    public function has(string $key): bool;
}

Implement with WordPress transients:

<?php

namespace Jenga\Cache;

use Jenga\Contract\CacheInterface;

class TransientCache implements CacheInterface
{
    public function __construct(private string $prefix = 'jenga_') {}

    public function get(string $key, mixed $default = null): mixed
    {
        $value = get_transient($this->prefix . $key);
        return $value === false ? $default : $value;
    }

    public function set(string $key, mixed $value, int $ttl = 3600): bool
    {
        return set_transient($this->prefix . $key, $value, $ttl);
    }

    public function delete(string $key): bool
    {
        return delete_transient($this->prefix . $key);
    }

    public function has(string $key): bool
    {
        return get_transient($this->prefix . $key) !== false;
    }
}

Implement with Redis (for sites using an object cache):

<?php

namespace Jenga\Cache;

use Jenga\Contract\CacheInterface;

class RedisCache implements CacheInterface
{
    public function __construct(
        private \Redis $redis,
        private string $prefix = 'jenga:',
    ) {}

    public function get(string $key, mixed $default = null): mixed
    {
        $value = $this->redis->get($this->prefix . $key);

        if ($value === false) {
            return $default;
        }

        $decoded = json_decode($value, true);
        return $decoded !== null ? $decoded : $value;
    }

    public function set(string $key, mixed $value, int $ttl = 3600): bool
    {
        $encoded = is_array($value) || is_object($value) ? json_encode($value) : $value;
        return $this->redis->setex($this->prefix . $key, $ttl, $encoded);
    }

    public function delete(string $key): bool
    {
        return $this->redis->del($this->prefix . $key) > 0;
    }

    public function has(string $key): bool
    {
        return $this->redis->exists($this->prefix . $key) > 0;
    }
}

Services that need caching depend on CacheInterface, not on a specific backend. Your API sync service might look like this:

<?php

namespace Jenga\Service;

use Jenga\Contract\ApiClientInterface;
use Jenga\Contract\CacheInterface;
use Jenga\Contract\PortfolioRepositoryInterface;

class ApiSyncService implements ApiClientInterface
{
    public function __construct(
        private PortfolioRepositoryInterface $repository,
        private CacheInterface $cache,
        private string $apiBaseUrl = '',
        private string $apiKey = '',
    ) {}

    public function syncOnSave(int $postId, \WP_Post $post): void
    {
        if (wp_is_post_revision($postId) || wp_is_post_autosave($postId)) {
            return;
        }

        $portfolioItem = $this->repository->findById($postId);

        if ($portfolioItem === null) {
            return;
        }

        $this->pushToApi($portfolioItem);
        $this->cache->delete("portfolio_{$postId}");
    }

    public function fetchFromApi(int $externalId): ?array
    {
        $cacheKey = "api_portfolio_{$externalId}";

        if ($this->cache->has($cacheKey)) {
            return $this->cache->get($cacheKey);
        }

        $response = wp_remote_get("{$this->apiBaseUrl}/portfolios/{$externalId}", [
            'headers' => ['Authorization' => "Bearer {$this->apiKey}"],
            'timeout' => 15,
        ]);

        if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
            return null;
        }

        $data = json_decode(wp_remote_retrieve_body($response), true);
        $this->cache->set($cacheKey, $data, 1800);

        return $data;
    }

    private function pushToApi(array $item): void
    {
        wp_remote_post("{$this->apiBaseUrl}/portfolios", [
            'headers' => [
                'Authorization' => "Bearer {$this->apiKey}",
                'Content-Type'  => 'application/json',
            ],
            'body'    => wp_json_encode($item),
            'timeout' => 15,
        ]);
    }
}

Observer Pattern Via WordPress Hooks With Typed Event Objects

WordPress hooks already implement the observer pattern: do_action publishes an event, and add_action subscribes to it. The problem is that WordPress hooks pass loose parameters (an ID here, a string there, sometimes an object), with no formal contract for what data an event carries. Typed event objects fix this.

Defining Event Objects

<?php

namespace Jenga\Event;

class PortfolioPublishedEvent
{
    public function __construct(
        public readonly int $postId,
        public readonly string $title,
        public readonly string $authorEmail,
        public readonly string $client,
        public readonly \DateTimeImmutable $publishedAt,
    ) {}

    public static function fromPost(\WP_Post $post): self
    {
        $author = get_userdata($post->post_author);

        return new self(
            postId: $post->ID,
            title: $post->post_title,
            authorEmail: $author ? $author->user_email : '',
            client: get_post_meta($post->ID, '_portfolio_client', true) ?: '',
            publishedAt: new \DateTimeImmutable($post->post_date_gmt),
        );
    }
}
<?php

namespace Jenga\Event;

class PortfolioUpdatedEvent
{
    public function __construct(
        public readonly int $postId,
        public readonly string $title,
        public readonly array $changedFields,
        public readonly \DateTimeImmutable $updatedAt,
    ) {}
}

Dispatching Typed Events

Inside the repository or a dedicated event dispatcher, fire the custom action with the event object:

// Inside PortfolioRepository::save(), after successful save:
if (!isset($data['id'])) {
    $event = PortfolioPublishedEvent::fromPost(get_post($postId));
    do_action('jenga_portfolio_published', $event);
} else {
    $event = new PortfolioUpdatedEvent(
        postId: $postId,
        title: $data['title'] ?? '',
        changedFields: array_keys($data),
        updatedAt: new \DateTimeImmutable('now'),
    );
    do_action('jenga_portfolio_updated', $event);
}

Subscribing to Typed Events

Listeners receive a strongly-typed object instead of a bag of positional arguments:

// In HookManager::register()
add_action('jenga_portfolio_published', [$this->notificationService, 'onPortfolioPublished']);
add_action('jenga_portfolio_published', [$this->apiSyncService, 'onPortfolioPublished']);
// In NotificationService
public function onPortfolioPublished(PortfolioPublishedEvent $event): void
{
    $this->mailer->send(
        $this->adminEmail,
        "New Portfolio Item: {$event->title}",
        "Client: {$event->client}\nPublished: {$event->publishedAt->format('Y-m-d H:i')}"
    );
}

The benefits are tangible. Your IDE can autocomplete $event->title. Static analysis tools like PHPStan can verify that you are accessing valid properties. If the event structure changes, the type system catches mismatches at analysis time, not at runtime in production.

Interface-Driven Development

Every service boundary in the plugin should be defined by an interface. This is the “D” in SOLID (Dependency Inversion Principle): high-level modules should not depend on low-level modules; both should depend on abstractions.

Practical Interface Design for WordPress

Good interfaces are small and focused (Interface Segregation Principle, the “I” in SOLID). Do not create a single PluginServiceInterface with 20 methods. Instead, create focused contracts:

<?php

namespace Jenga\Contract;

interface MailerInterface
{
    public function send(string $to, string $subject, string $body, array $headers = []): bool;
    public function sendHtml(string $to, string $subject, string $htmlBody, array $headers = []): bool;
}

interface CacheInterface
{
    public function get(string $key, mixed $default = null): mixed;
    public function set(string $key, mixed $value, int $ttl = 3600): bool;
    public function delete(string $key): bool;
    public function has(string $key): bool;
}

interface PortfolioRepositoryInterface
{
    public function findById(int $id): ?array;
    public function findAll(int $limit = 10, int $offset = 0): array;
    public function findByClient(string $client): array;
    public function findPublished(int $limit = 10): array;
    public function save(array $data): int;
    public function delete(int $id): bool;
    public function count(array $criteria = []): int;
}

interface ApiClientInterface
{
    public function fetchFromApi(int $externalId): ?array;
}

interface SettingsRepositoryInterface
{
    public function get(string $key, mixed $default = null): mixed;
    public function set(string $key, mixed $value): bool;
    public function getGroup(string $group): array;
}

A Settings Repository Implementation

Here is a concrete example of the SettingsRepositoryInterface backed by WordPress options:

<?php

namespace Jenga\Repository;

use Jenga\Contract\SettingsRepositoryInterface;

class WpOptionsSettingsRepository implements SettingsRepositoryInterface
{
    public function __construct(private string $prefix = 'jenga_') {}

    public function get(string $key, mixed $default = null): mixed
    {
        return get_option($this->prefix . $key, $default);
    }

    public function set(string $key, mixed $value): bool
    {
        return update_option($this->prefix . $key, $value);
    }

    public function getGroup(string $group): array
    {
        global $wpdb;

        $results = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s",
                $this->prefix . $group . '_%'
            ),
            ARRAY_A
        );

        $settings = [];
        $prefixLength = strlen($this->prefix . $group . '_');

        foreach ($results as $row) {
            $key = substr($row['option_name'], $prefixLength);
            $settings[$key] = maybe_unserialize($row['option_value']);
        }

        return $settings;
    }
}

Now, any service that needs configuration depends on SettingsRepositoryInterface rather than calling get_option directly. During testing, you substitute a simple array-backed implementation:

<?php

namespace Jenga\Tests\Stub;

use Jenga\Contract\SettingsRepositoryInterface;

class ArraySettingsRepository implements SettingsRepositoryInterface
{
    private array $data = [];

    public function __construct(array $initial = []) {
        $this->data = $initial;
    }

    public function get(string $key, mixed $default = null): mixed
    {
        return $this->data[$key] ?? $default;
    }

    public function set(string $key, mixed $value): bool
    {
        $this->data[$key] = $value;
        return true;
    }

    public function getGroup(string $group): array
    {
        return array_filter(
            $this->data,
            fn (string $k) => str_starts_with($k, $group . '_'),
            ARRAY_FILTER_USE_KEY
        );
    }
}

Three implementations of the same interface, each serving a different context: production WordPress, testing, and potentially a future migration to a custom database table. The consuming code never changes.

SOLID Principles Applied to WordPress

Let us walk through each principle with WordPress-specific examples.

Single Responsibility Principle (SRP)

A class should have one reason to change. In WordPress terms, this means your admin settings class should not also handle email sending. Your custom post type registration class should not also contain the template rendering logic.

Bad:

class PortfolioManager {
    public function register() { /* CPT registration */ }
    public function renderArchive() { /* Template logic */ }
    public function handleAjaxSave() { /* AJAX handler */ }
    public function sendNotification() { /* Email */ }
    public function syncToApi() { /* API call */ }
}

Good:

class PortfolioPostType { public function register(): void { /* CPT only */ } }
class PortfolioRepository { public function save(array $data): int { /* Persistence only */ } }
class NotificationService { public function onPublish(PortfolioPublishedEvent $e): void { /* Email only */ } }
class ApiSyncService { public function syncOnSave(int $id, \WP_Post $post): void { /* Sync only */ } }

Each class changes for exactly one reason: the post type configuration changes, the storage mechanism changes, the notification template changes, or the API contract changes.

Open/Closed Principle (OCP)

Classes should be open for extension but closed for modification. The strategy pattern section above demonstrates this perfectly. Adding a new mailer (SendGrid, Mailgun, SES) means creating a new class that implements MailerInterface. You never modify NotificationService, WpMailer, or any existing code.

Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types. If SmtpMailer implements MailerInterface, then every place that uses MailerInterface must work correctly with SmtpMailer. This means SmtpMailer::send() must accept the same parameters and return a boolean, not throw an exception where WpMailer::send() would return false.

A violation example: if SmtpMailer::send() required a fifth parameter for SMTP-specific headers, it would break the contract. Keep implementations honest with their interfaces.

Interface Segregation Principle (ISP)

Clients should not be forced to depend on methods they do not use. If some services only need to read settings but never write them, split the interface:

interface ReadableSettingsInterface
{
    public function get(string $key, mixed $default = null): mixed;
    public function getGroup(string $group): array;
}

interface WritableSettingsInterface extends ReadableSettingsInterface
{
    public function set(string $key, mixed $value): bool;
}

Services that only read configuration depend on ReadableSettingsInterface. Admin classes that also write settings depend on WritableSettingsInterface. The implementation class satisfies both, but consumers declare only what they actually need.

Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. The entire article demonstrates this principle. NotificationService (high-level business logic) does not depend on WpMailer (low-level WordPress function wrapper). Both depend on MailerInterface (the abstraction).

Unit Testing With Dependency Injection

The entire architecture we have built pays off most dramatically in testing. Without DI, testing a WordPress plugin requires loading the entire WordPress bootstrap, creating test databases, and running slow integration tests. With DI, unit tests run in isolation at full speed.

Test Bootstrap

<?php
// tests/bootstrap.php

require_once dirname(__DIR__) . '/vendor/autoload.php';

// Brain\Monkey sets up WordPress function mocks
\Brain\Monkey\setUp();

Configure PHPUnit:

<!-- phpunit.xml -->
<phpunit bootstrap="tests/bootstrap.php" colors="true">
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory suffix=".php">src</directory>
        </include>
    </source>
</phpunit>

Testing NotificationService

<?php

namespace Jenga\Tests\Unit\Service;

use Jenga\Service\NotificationService;
use Jenga\Contract\MailerInterface;
use Jenga\Contract\PortfolioRepositoryInterface;
use Jenga\Event\PortfolioPublishedEvent;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase;

class NotificationServiceTest extends TestCase
{
    use MockeryPHPUnitIntegration;

    private MailerInterface|Mockery\MockInterface $mailer;
    private PortfolioRepositoryInterface|Mockery\MockInterface $repository;
    private NotificationService $service;

    protected function setUp(): void
    {
        parent::setUp();

        $this->mailer = Mockery::mock(MailerInterface::class);
        $this->repository = Mockery::mock(PortfolioRepositoryInterface::class);

        $this->service = new NotificationService(
            $this->mailer,
            $this->repository,
            '[email protected]'
        );
    }

    public function testOnStatusChangeIgnoresNonPortfolioPosts(): void
    {
        $post = Mockery::mock(\WP_Post::class);
        $post->post_type = 'post';

        $this->mailer->shouldNotReceive('send');

        $this->service->onStatusChange('publish', 'draft', $post);
    }

    public function testOnStatusChangeIgnoresAlreadyPublishedPosts(): void
    {
        $post = Mockery::mock(\WP_Post::class);
        $post->post_type = 'portfolio';
        $post->ID = 42;

        $this->mailer->shouldNotReceive('send');

        $this->service->onStatusChange('publish', 'publish', $post);
    }

    public function testOnStatusChangeSendsEmailWhenPortfolioPublished(): void
    {
        $post = Mockery::mock(\WP_Post::class);
        $post->post_type = 'portfolio';
        $post->ID = 42;
        $post->post_title = 'New Project';

        $this->repository->shouldReceive('findById')
            ->with(42)
            ->once()
            ->andReturn([
                'id' => 42,
                'title' => 'New Project',
                'client' => 'Acme Corp',
                'url' => 'https://example.com',
            ]);

        $this->mailer->shouldReceive('send')
            ->with(
                '[email protected]',
                'New Portfolio Item Published: New Project',
                Mockery::on(fn (string $body) =>
                    str_contains($body, 'Acme Corp') &&
                    str_contains($body, 'New Project')
                )
            )
            ->once()
            ->andReturn(true);

        $this->service->onStatusChange('publish', 'draft', $post);
    }

    public function testOnStatusChangeHandlesEmptyAdminEmail(): void
    {
        $service = new NotificationService(
            $this->mailer,
            $this->repository,
            '' // No admin email configured
        );

        $post = Mockery::mock(\WP_Post::class);
        $post->post_type = 'portfolio';
        $post->ID = 42;
        $post->post_title = 'Test';

        $this->mailer->shouldNotReceive('send');
        $this->repository->shouldNotReceive('findById');

        $service->onStatusChange('publish', 'draft', $post);
    }

    public function testOnPortfolioPublishedSendsFormattedEmail(): void
    {
        $event = new PortfolioPublishedEvent(
            postId: 42,
            title: 'Amazing Project',
            authorEmail: '[email protected]',
            client: 'BigCorp',
            publishedAt: new \DateTimeImmutable('2024-01-15 10:30:00'),
        );

        $this->mailer->shouldReceive('send')
            ->with(
                '[email protected]',
                'New Portfolio Item: Amazing Project',
                Mockery::on(fn (string $body) =>
                    str_contains($body, 'BigCorp') &&
                    str_contains($body, '2024-01-15')
                )
            )
            ->once()
            ->andReturn(true);

        $this->service->onPortfolioPublished($event);
    }
}

Testing the Repository Pattern

Since the repository implements an interface, you test the interface contract:

<?php

namespace Jenga\Tests\Unit\Repository;

use PHPUnit\Framework\TestCase;
use Jenga\Contract\PortfolioRepositoryInterface;

abstract class PortfolioRepositoryContractTest extends TestCase
{
    abstract protected function createRepository(): PortfolioRepositoryInterface;

    public function testSaveAndRetrieve(): void
    {
        $repo = $this->createRepository();

        $id = $repo->save([
            'title' => 'Test Portfolio',
            'description' => 'A test project',
            'status' => 'publish',
            'client' => 'Test Client',
        ]);

        $this->assertGreaterThan(0, $id);

        $item = $repo->findById($id);

        $this->assertNotNull($item);
        $this->assertSame('Test Portfolio', $item['title']);
        $this->assertSame('Test Client', $item['client']);
    }

    public function testFindByIdReturnsNullForMissing(): void
    {
        $repo = $this->createRepository();
        $this->assertNull($repo->findById(999999));
    }

    public function testDeleteRemovesItem(): void
    {
        $repo = $this->createRepository();

        $id = $repo->save([
            'title' => 'To Delete',
            'status' => 'draft',
        ]);

        $this->assertTrue($repo->delete($id));
        $this->assertNull($repo->findById($id));
    }

    public function testCountReflectsSavedItems(): void
    {
        $repo = $this->createRepository();
        $initialCount = $repo->count();

        $repo->save(['title' => 'Item 1', 'status' => 'publish']);
        $repo->save(['title' => 'Item 2', 'status' => 'publish']);

        $this->assertSame($initialCount + 2, $repo->count());
    }
}

This abstract test class defines the contract that any PortfolioRepositoryInterface implementation must satisfy. The real PortfolioRepository backed by WordPress would need an integration test (with a test database), but any in-memory or stub implementation can run these tests purely:

<?php

namespace Jenga\Tests\Unit\Repository;

use Jenga\Contract\PortfolioRepositoryInterface;

class InMemoryPortfolioRepository implements PortfolioRepositoryInterface
{
    private array $items = [];
    private int $nextId = 1;

    public function findById(int $id): ?array
    {
        return $this->items[$id] ?? null;
    }

    public function findAll(int $limit = 10, int $offset = 0): array
    {
        return array_slice(array_values($this->items), $offset, $limit);
    }

    public function findByClient(string $client): array
    {
        return array_values(array_filter(
            $this->items,
            fn (array $item) => ($item['client'] ?? '') === $client
        ));
    }

    public function findPublished(int $limit = 10): array
    {
        $published = array_filter(
            $this->items,
            fn (array $item) => ($item['status'] ?? '') === 'publish'
        );

        return array_slice(array_values($published), 0, $limit);
    }

    public function save(array $data): int
    {
        $id = $data['id'] ?? $this->nextId++;
        $this->items[$id] = array_merge($data, ['id' => $id]);
        return $id;
    }

    public function delete(int $id): bool
    {
        if (!isset($this->items[$id])) {
            return false;
        }

        unset($this->items[$id]);
        return true;
    }

    public function count(array $criteria = []): int
    {
        if (empty($criteria)) {
            return count($this->items);
        }

        $filtered = $this->items;

        if (isset($criteria['status'])) {
            $filtered = array_filter(
                $filtered,
                fn (array $item) => ($item['status'] ?? '') === $criteria['status']
            );
        }

        if (isset($criteria['client'])) {
            $filtered = array_filter(
                $filtered,
                fn (array $item) => ($item['client'] ?? '') === $criteria['client']
            );
        }

        return count($filtered);
    }
}

class InMemoryPortfolioRepositoryTest extends PortfolioRepositoryContractTest
{
    protected function createRepository(): PortfolioRepositoryInterface
    {
        return new InMemoryPortfolioRepository();
    }
}

Testing the Container Itself

You should also verify that your container resolves all services correctly:

<?php

namespace Jenga\Tests\Unit;

use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
use Jenga\Container;
use Jenga\Contract\MailerInterface;
use Jenga\Contract\PortfolioRepositoryInterface;
use Jenga\Service\NotificationService;
use Jenga\Hook\HookManager;

class ContainerTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        Monkey\setUp();

        // Mock WordPress functions used during container build
        Functions\when('plugin_dir_path')->justReturn('/fake/path/');
        Functions\when('plugin_dir_url')->justReturn('https://example.com/plugins/jenga/');
        Functions\when('get_option')->justReturn('');
    }

    protected function tearDown(): void
    {
        Monkey\tearDown();
        parent::tearDown();
    }

    public function testContainerResolvesMailerInterface(): void
    {
        $container = Container::build('/fake/plugin.php');
        $mailer = $container->get(MailerInterface::class);

        $this->assertInstanceOf(MailerInterface::class, $mailer);
    }

    public function testContainerResolvesRepositoryInterface(): void
    {
        $container = Container::build('/fake/plugin.php');
        $repo = $container->get(PortfolioRepositoryInterface::class);

        $this->assertInstanceOf(PortfolioRepositoryInterface::class, $repo);
    }

    public function testContainerResolvesNotificationService(): void
    {
        $container = Container::build('/fake/plugin.php');
        $service = $container->get(NotificationService::class);

        $this->assertInstanceOf(NotificationService::class, $service);
    }

    public function testContainerResolvesHookManager(): void
    {
        $container = Container::build('/fake/plugin.php');
        $hooks = $container->get(HookManager::class);

        $this->assertInstanceOf(HookManager::class, $hooks);
    }
}

This test catches wiring mistakes early. If someone adds a new dependency to NotificationService but forgets to register it in the container, this test fails immediately.

Complete Working Plugin: Full DI Container and Tests

Let us bring everything together into a complete, working plugin. Below is the final structure and the key integration points that make it all function as a unit.

The Plugin Bootstrap

<?php

namespace Jenga;

class Plugin
{
    private static ?\Psr\Container\ContainerInterface $container = null;

    public static function boot(string $pluginFile): void
    {
        self::$container = Container::build($pluginFile);

        // Register hooks
        $hookManager = self::$container->get(Hook\HookManager::class);
        $hookManager->register();

        // Register activation/deactivation hooks
        register_activation_hook($pluginFile, [self::class, 'activate']);
        register_deactivation_hook($pluginFile, [self::class, 'deactivate']);
    }

    public static function getContainer(): \Psr\Container\ContainerInterface
    {
        if (self::$container === null) {
            throw new \RuntimeException('Plugin has not been booted.');
        }

        return self::$container;
    }

    public static function activate(): void
    {
        // Flush rewrite rules for custom post types
        $postType = self::$container->get(PostType\PortfolioPostType::class);
        $postType->register();
        flush_rewrite_rules();
    }

    public static function deactivate(): void
    {
        flush_rewrite_rules();
    }
}

The static getContainer() method provides an escape hatch for WordPress contexts where you cannot use constructor injection (template files, shortcode callbacks, legacy code integration). Use it sparingly. The goal is to resolve services at the edges and pass them inward.

The Post Type Registration

<?php

namespace Jenga\PostType;

class PortfolioPostType
{
    private const POST_TYPE = 'portfolio';

    public function register(): void
    {
        register_post_type(self::POST_TYPE, [
            'labels' => [
                'name'               => 'Portfolios',
                'singular_name'      => 'Portfolio',
                'add_new_item'       => 'Add New Portfolio Item',
                'edit_item'          => 'Edit Portfolio Item',
                'new_item'           => 'New Portfolio Item',
                'view_item'          => 'View Portfolio Item',
                'search_items'       => 'Search Portfolios',
                'not_found'          => 'No portfolio items found',
                'not_found_in_trash' => 'No portfolio items found in Trash',
            ],
            'public'       => true,
            'has_archive'  => true,
            'menu_icon'    => 'dashicons-portfolio',
            'supports'     => ['title', 'editor', 'thumbnail', 'excerpt'],
            'rewrite'      => ['slug' => 'portfolio'],
            'show_in_rest' => true,
        ]);

        register_taxonomy('portfolio_type', self::POST_TYPE, [
            'labels' => [
                'name'          => 'Portfolio Types',
                'singular_name' => 'Portfolio Type',
            ],
            'hierarchical' => true,
            'show_in_rest' => true,
            'rewrite'      => ['slug' => 'portfolio-type'],
        ]);
    }

    public function appendPortfolioMeta(string $content): string
    {
        if (!is_singular(self::POST_TYPE)) {
            return $content;
        }

        $postId = get_the_ID();
        $client = get_post_meta($postId, '_portfolio_client', true);
        $url    = get_post_meta($postId, '_portfolio_project_url', true);
        $tech   = get_post_meta($postId, '_portfolio_technologies', true);

        if (empty($client) && empty($url) && empty($tech)) {
            return $content;
        }

        $meta = '
'; if ($client) { $meta .= sprintf('

Client: %s

', esc_html($client)); } if ($url) { $meta .= sprintf( '

Project URL: %s

', esc_url($url), esc_html($url) ); } if ($tech) { $meta .= sprintf('

Technologies: %s

', esc_html($tech)); } $meta .= '
'; return $content . $meta; } }

The Settings Service

<?php

namespace Jenga\Service;

use Jenga\Contract\SettingsRepositoryInterface;

class SettingsService
{
    public function __construct(
        private SettingsRepositoryInterface $settings,
        private string $optionPrefix = 'jenga_',
    ) {}

    public function registerMenuPage(): void
    {
        add_options_page(
            'Jenga Settings',
            'Jenga',
            'manage_options',
            'jenga-settings',
            [$this, 'renderSettingsPage']
        );
    }

    public function registerSettings(): void
    {
        register_setting('jenga_settings_group', $this->optionPrefix . 'admin_email', [
            'type'              => 'string',
            'sanitize_callback' => 'sanitize_email',
        ]);

        register_setting('jenga_settings_group', $this->optionPrefix . 'api_base_url', [
            'type'              => 'string',
            'sanitize_callback' => 'esc_url_raw',
        ]);

        register_setting('jenga_settings_group', $this->optionPrefix . 'api_key', [
            'type'              => 'string',
            'sanitize_callback' => 'sanitize_text_field',
        ]);

        register_setting('jenga_settings_group', $this->optionPrefix . 'mail_method', [
            'type'              => 'string',
            'sanitize_callback' => fn ($val) => in_array($val, ['wp_mail', 'smtp'], true) ? $val : 'wp_mail',
        ]);

        add_settings_section(
            'jenga_general',
            'General Settings',
            null,
            'jenga-settings'
        );

        add_settings_field(
            'jenga_admin_email',
            'Admin Email',
            [$this, 'renderEmailField'],
            'jenga-settings',
            'jenga_general'
        );

        add_settings_field(
            'jenga_mail_method',
            'Mail Method',
            [$this, 'renderMailMethodField'],
            'jenga-settings',
            'jenga_general'
        );
    }

    public function renderSettingsPage(): void
    {
        if (!current_user_can('manage_options')) {
            return;
        }

        echo '
'; echo '

Jenga Portfolio Settings

'; echo '
'; settings_fields('jenga_settings_group'); do_settings_sections('jenga-settings'); submit_button(); echo '
'; echo '
'; } public function renderEmailField(): void { $value = $this->settings->get('admin_email', ''); printf( '', esc_attr($this->optionPrefix), esc_attr($value) ); } public function renderMailMethodField(): void { $value = $this->settings->get('mail_method', 'wp_mail'); printf( '', esc_attr($this->optionPrefix), selected($value, 'wp_mail', false), selected($value, 'smtp', false) ); } }

The Complete Container Wiring

With all services built, the final container definition looks like this:

<?php

namespace Jenga;

use DI\ContainerBuilder;
use Psr\Container\ContainerInterface;
use Jenga\Contract\MailerInterface;
use Jenga\Contract\PortfolioRepositoryInterface;
use Jenga\Contract\ApiClientInterface;
use Jenga\Contract\CacheInterface;
use Jenga\Contract\SettingsRepositoryInterface;
use Jenga\Mailer\WpMailer;
use Jenga\Mailer\SmtpMailer;
use Jenga\Repository\PortfolioRepository;
use Jenga\Repository\WpOptionsSettingsRepository;
use Jenga\Cache\TransientCache;
use Jenga\Service\ApiSyncService;
use Jenga\Service\NotificationService;
use Jenga\Service\SettingsService;
use Jenga\PostType\PortfolioPostType;
use Jenga\Hook\HookManager;

class Container
{
    public static function build(string $pluginFile): ContainerInterface
    {
        $builder = new ContainerBuilder();

        $builder->addDefinitions([
            // Plugin metadata
            'plugin.file' => $pluginFile,
            'plugin.path' => plugin_dir_path($pluginFile),
            'plugin.url'  => plugin_dir_url($pluginFile),

            // Settings repository (needed early by other bindings)
            SettingsRepositoryInterface::class => \DI\autowire(WpOptionsSettingsRepository::class)
                ->constructorParameter('prefix', 'jenga_'),

            // Cache
            CacheInterface::class => \DI\autowire(TransientCache::class)
                ->constructorParameter('prefix', 'jenga_'),

            // Mailer - resolved dynamically based on settings
            MailerInterface::class => \DI\factory(function (ContainerInterface $c) {
                $settings = $c->get(SettingsRepositoryInterface::class);
                $method = $settings->get('mail_method', 'wp_mail');

                if ($method === 'smtp') {
                    return new SmtpMailer(
                        $settings->get('smtp_host', ''),
                        (int) $settings->get('smtp_port', 587),
                        $settings->get('smtp_user', ''),
                        $settings->get('smtp_pass', ''),
                    );
                }

                return new WpMailer();
            }),

            // Repository
            PortfolioRepositoryInterface::class => \DI\autowire(PortfolioRepository::class),

            // Services
            NotificationService::class => \DI\factory(function (ContainerInterface $c) {
                $settings = $c->get(SettingsRepositoryInterface::class);
                return new NotificationService(
                    $c->get(MailerInterface::class),
                    $c->get(PortfolioRepositoryInterface::class),
                    $settings->get('admin_email', get_option('admin_email')),
                );
            }),

            ApiSyncService::class => \DI\factory(function (ContainerInterface $c) {
                $settings = $c->get(SettingsRepositoryInterface::class);
                return new ApiSyncService(
                    $c->get(PortfolioRepositoryInterface::class),
                    $c->get(CacheInterface::class),
                    $settings->get('api_base_url', ''),
                    $settings->get('api_key', ''),
                );
            }),

            SettingsService::class => \DI\factory(function (ContainerInterface $c) {
                return new SettingsService(
                    $c->get(SettingsRepositoryInterface::class),
                    'jenga_',
                );
            }),

            // Post type
            PortfolioPostType::class => \DI\autowire(),

            // Hook manager - autowired, resolves all constructor deps
            HookManager::class => \DI\autowire(),
        ]);

        return $builder->build();
    }
}

Every interface maps to one concrete class. Every scalar value (prefixes, URLs, API keys) is sourced from settings or WordPress options. The container resolves the entire dependency tree from any entry point. Requesting HookManager triggers resolution of NotificationService, which triggers resolution of MailerInterface and PortfolioRepositoryInterface, and so on.

Running the Tests

With the structure in place, running tests is straightforward:

# Run all unit tests
./vendor/bin/phpunit --testsuite Unit

# Run with coverage
./vendor/bin/phpunit --testsuite Unit --coverage-html coverage/

# Run a specific test file
./vendor/bin/phpunit tests/Unit/Service/NotificationServiceTest.php

# Run a specific test method
./vendor/bin/phpunit --filter testOnStatusChangeSendsEmailWhenPortfolioPublished

Each test runs without WordPress, without a database, and without network access. On a modern machine, the full test suite runs in under a second.

Practical Considerations and Trade-offs

This architecture is not free. There are real costs and trade-offs worth understanding before you adopt it.

When This Architecture Is Overkill

A plugin that registers a shortcode and outputs some HTML does not need a service container. A plugin that adds a widget with three settings fields does not need the repository pattern. Architecture should match complexity. If your plugin has fewer than five classes and no external dependencies, flat procedural code with proper namespacing is perfectly adequate.

The inflection point comes when you find yourself doing any of these: duplicating query logic across multiple files, struggling to change one behavior without breaking another, avoiding changes because you cannot predict side effects, or skipping tests because they require too much setup. That is when these patterns start paying for themselves.

Performance Overhead

Service containers add a small amount of overhead to the bootstrap phase. PHP-DI’s compiled container mode eliminates most of this for production:

$builder = new ContainerBuilder();
$builder->enableCompilation(__DIR__ . '/../var/cache');
$builder->writeProxiesToFile(true, __DIR__ . '/../var/cache/proxies');

The compiled container generates a PHP class that hard-codes all the factory logic, removing reflection overhead entirely. For plugins that load on every request (not just admin), this matters.

Autoloading itself has negligible overhead. Composer’s class map (generated with composer dump-autoload --optimize) is a single array lookup per class.

Team Adoption

If your team is used to writing WordPress the traditional way, this architecture requires learning. Concepts like interfaces, type hints, and constructor injection may be unfamiliar to developers who have only worked in the WordPress ecosystem. Plan for a learning curve, and introduce patterns incrementally. Start with PSR-4 autoloading and namespaces. Add interfaces for one service boundary. Introduce the container once the team is comfortable with the underlying concepts.

Compatibility With WordPress Conventions

This architecture works alongside WordPress conventions; it does not replace them. You still use add_action and add_filter because that is how WordPress works. You still use register_post_type and WP_Query because those are the APIs WordPress provides. The difference is where and how you call them: behind interfaces, inside focused classes, wired together by a container, and testable in isolation.

The WordPress ecosystem has plugins that scan for specific function calls (register_activation_hook, add_action) to understand plugin behavior. Your plugin remains fully compatible with these tools because the hook registration happens in HookManager::register(), which calls the standard WordPress functions.

Migration Strategy for Existing Plugins

If you have an existing plugin and want to adopt this architecture, do not rewrite everything at once. Follow this incremental approach:

First, add Composer and PSR-4 autoloading. Move classes out of the main plugin file into src/. This change is fully backward-compatible.

Second, introduce interfaces for one service boundary. Pick the boundary with the most pain (usually the one you are currently struggling to test). Create the interface, extract the implementation, update call sites.

Third, add a container. Start with only the service you extracted in step two. Other code continues to work the traditional way.

Fourth, migrate additional services one at a time. Each migration is a discrete pull request that can be reviewed and tested independently.

This approach takes weeks or months for a large plugin, but each step delivers immediate value: better organization, easier testing, and clearer boundaries.

Key Takeaways

WordPress plugin development does not have to mean abandoning software engineering principles. The patterns in this article are battle-tested across the PHP ecosystem, from Laravel to Symfony to Drupal. Applying them to WordPress requires some adaptation, particularly around hook registration and the global nature of many WordPress APIs, but the core concepts transfer directly.

The architecture we built follows a clear layering: interfaces define contracts, implementations fulfill them, a container wires them together, and a hook manager connects them to WordPress. Each layer has a single responsibility, and dependencies always point inward toward abstractions rather than outward toward concrete implementations.

The payoff is a plugin that you can test without WordPress, extend without modification, and maintain without fear. That is what enterprise architecture actually means: not complexity for its own sake, but structure that makes change safe and predictable.

Start with the problem you have. If you cannot test your plugin, introduce an interface and a mock. If you are duplicating query logic, extract a repository. If changing a mailer requires editing five files, add a strategy interface. Let the pain guide the architecture, and the patterns will earn their place in your codebase.

Share this article

Priya Sharma

Frontend engineer specializing in Gutenberg block development and modern JavaScript in WordPress. Advocates for testing and code quality in the WordPress ecosystem.