Object-Oriented Architecture in enterprise WordPress plugins running on PHP 8.4 / 8.5 addresses a critical engineering objective: decoupling pure domain business logic (Domain Layer) from procedural global state inherent to WordPress core ($wpdb, $wp_filter, $post).
Within the modern WordPress 7.0+ enterprise ecosystem, the obsolete antipattern of "every class is a global Singleton registering hooks in its constructor" has been replaced by a modular architecture built upon four foundational pillars:
final readonly class, Asymmetric Visibility public private(set), and Constructor Property Promotion): Service classes are declared immutable (readonly), ingest dependencies strictly via typed constructors, and maintain zero mutable global state.ReflectionClass to automatically resolve constructor dependency graphs and orchestrate object lifecycles.HookSubscriberInterface / Event Dispatcher): Strict separation between object instantiation (__construct) and WordPress event registration (add_action / add_filter). This enables instant instantiation of any service in isolated PHPUnit test suites (1 millisecond execution) without booting WordPress core or a database.$subscriber->handle(...)): Native PHP 8.1–8.4+ syntax enabling typed method references in add_action(), fully validated by PHPStan / Psalm at Level 9 static analysis.Conceive plugin architecture through the lens of Hexagonal / Clean Architecture: WordPress core and its hook dispatcher serve as external infrastructure adapters, delegating incoming events to strongly-typed domain services via subscriber adapters.
PSR-11 DI Container Hook Subscriber Pattern First-Class Callable Asymmetric Visibility Brain Monkey[Bootstrap: agentic-lab-plugin.php]
│
▼
[PSR-11 Auto-Wiring Container::boot()]
┌──────────────────────┴──────────────────────┐
▼ ▼
[Infrastructure Adapter] [Domain Service (Readonly)]
WpMailGateway implements MailerInterface LeadScoringService(MailerInterface)
│ │
└──────────────────────┬──────────────────────┘
▼
[Subscriber: LeadHookSubscriber]
getSubscribedHooks():
'save_post_lead' => $this->onLeadSaved(...)
│
▼
[Hook Dispatcher: Registration in $wp_filter]Map abstract interfaces (LoggerInterface, CacheInterface) to concrete implementations inside container configuration.
Inspect constructor parameter types via ReflectionClass to recursively build the dependency graph of immutable readonly services.
Iterate across classes implementing HookSubscriberInterface and extract their declarative hook maps without executing domain logic.
Attach closures to $wp_filter using First-Class Callables. Heavy service instantiation is deferred until the event fires.
Triggering a core hook invokes the subscriber adapter, which unpacks clean DTOs and passes them to the pure domain service.
add_action() from constructors and injecting dependencies via interfaces, discount engines, lead scorers, and AI prompt pipelines are tested in pure PHPUnit in under 0.2s without MySQL.WP_AI_Client on general web requests, deferring instantiation strictly to when target hooks execute.HookDispatcher, third-party developers can disable or swap internal handlers via the container without hacky Reflection operations against global $wp_filter.readonly class structures and asymmetric visibility public private(set) prevents accidental mutation of payment gateway credentials or authorization tokens by third-party filters.Contrast the procedural legacy singleton model with modern modular architecture in PHP 8.4+:
Below is a production-ready architectural skeleton in PHP 8.4+ (readonly classes, First-Class Callables, Reflection Auto-Wiring) designed as a reusable foundation for mission-critical plugins.
<?php
/**
* Plugin Name: Fullstack Agentic Lab — Enterprise DI Container & Hook Dispatcher
* Description: Reference OOP plugin architecture in PHP 8.4+ with auto-wiring and Hook Subscribers.
* Version: 2.1.0
* Author: Artur Yusupov (Fullstack Agentic Lab)
*/
declare(strict_types=1);
namespace AgenticLab\Core\Container;
use Closure;
use ReflectionClass;
use ReflectionNamedType;
use RuntimeException;
use WP_Post;
if (!defined('ABSPATH')) {
exit;
}
/**
* 1. Declarative WordPress event subscriber contract.
*/
interface HookSubscriberInterface
{
/**
* Returns hook subscription map: ['hook_name' => 'methodName' | ['methodName', priority, acceptedArgs]]
*
* @return array<string, string|array{0: string, 1?: int, 2?: int}>
*/
public static function getSubscribedHooks(): array;
}
/**
* 2. Infrastructure telemetry logger contract (Dependency Inversion Principle).
*/
interface TelemetryLoggerInterface
{
public function recordEvent(string $event, array $context = []): void;
}
/**
* 3. WordPress infrastructure logger implementation.
*/
final readonly class WpErrorLogTelemetry implements TelemetryLoggerInterface
{
public function recordEvent(string $event, array $context = []): void
{
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf('[AGENTIC-LAB][%s] %s', $event, wp_json_encode($context)));
}
}
}
/**
* 4. Pure domain service (0 WordPress core function dependencies — 100% testable in PHPUnit).
*/
final readonly class CaseStudyQualityValidator
{
public function __construct(
private TelemetryLoggerInterface $telemetry
) {}
public function validateWordCount(int $postId, string $rawContent): bool
{
$cleanText = trim(strip_tags($rawContent));
$wordCount = str_word_count($cleanText);
$isValid = $wordCount >= 150;
$this->telemetry->recordEvent('case_study_validated', [
'post_id' => $postId,
'word_count' => $wordCount,
'is_valid' => $isValid,
]);
return $isValid;
}
}
/**
* 5. Hook Subscriber (Adapter connecting WordPress events to Domain Services).
*/
final readonly class CaseStudyPublicationSubscriber implements HookSubscriberInterface
{
public function __construct(
private CaseStudyQualityValidator $validator
) {}
public static function getSubscribedHooks(): array
{
return [
'save_post_case_study' => ['onCaseStudySaved', 10, 2],
];
}
public function onCaseStudySaved(int $postId, WP_Post $post): void
{
if ((defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) || $post->post_status !== 'publish') {
return;
}
$this->validator->validateWordCount($postId, $post->post_content);
}
}
/**
* 6. Lightweight PSR-11 DI Container with Reflection Auto-Wiring and Lazy Hook Dispatcher.
*/
final class ServiceContainer
{
/** @var array<class-string, object> */
private array $instances = [];
/** @var array<class-string, class-string|Closure> */
private array $bindings = [];
/**
* @param class-string $abstract
* @param class-string|Closure $concrete
*/
public function bind(string $abstract, string|Closure $concrete): void
{
$this->bindings[$abstract] = $concrete;
}
/**
* @template T of object
* @param class-string<T> $id
* @return T
*/
public function get(string $id): object
{
if (isset($this->instances[$id])) {
/** @var T */
return $this->instances[$id];
}
$target = $this->bindings[$id] ?? $id;
if ($target instanceof Closure) {
/** @var T $resolved */
$resolved = $target($this);
return $this->instances[$id] = $resolved;
}
$reflector = new ReflectionClass($target);
if (!$reflector->isInstantiable()) {
throw new RuntimeException(sprintf('Class "%s" is not instantiable.', $target));
}
$constructor = $reflector->getConstructor();
if ($constructor === null) {
/** @var T $instance */
$instance = new $target();
return $this->instances[$id] = $instance;
}
$dependencies = [];
foreach ($constructor->getParameters() as $param) {
$type = $param->getType();
if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) {
throw new RuntimeException(sprintf('Cannot auto-wire scalar parameter "$%s" in "%s".', $param->getName(), $target));
}
/** @var class-string $depClass */
$depClass = $type->getName();
$dependencies[] = $this->get($depClass);
}
/** @var T $instance */
$instance = $reflector->newInstanceArgs($dependencies);
return $this->instances[$id] = $instance;
}
/**
* Register subscriber using lazy closures (Lazy Hook Registration).
* The subscriber instance and its dependencies are constructed ONLY when the hook fires!
*
* @param class-string<HookSubscriberInterface> $subscriberClass
*/
public function registerSubscriber(string $subscriberClass): void
{
foreach ($subscriberClass::getSubscribedHooks() as $hookName => $params) {
$method = is_array($params) ? $params[0] : $params;
$priority = is_array($params) ? ($params[1] ?? 10) : 10;
$acceptedArgs = is_array($params) ? ($params[2] ?? 1) : 1;
add_filter(
$hookName,
function (mixed ...$args) use ($subscriberClass, $method): mixed {
$subscriber = $this->get($subscriberClass);
return $subscriber->{$method}(...$args);
},
$priority,
$acceptedArgs
);
}
}
}
// Container bootstrap on plugins_loaded hook
add_action('plugins_loaded', static function (): void {
$container = new ServiceContainer();
$container->bind(TelemetryLoggerInterface::class, WpErrorLogTelemetry::class);
$container->registerSubscriber(CaseStudyPublicationSubscriber::class);
});The Mechanism: An engineer invokes add_action('init', [$this, 'register']) directly inside the __construct() method of a class.
Why Developers Do It: Attempting to combine object allocation and initialization into a single convenient new MyFeature() call.
Production Consequence: Violates the Single Responsibility Principle (SRP). The class cannot be instantiated inside a PHPUnit test without immediately triggering WordPress global state mutations. Furthermore, if a second instance of the class is accidentally created (e.g. to call a helper method), all hooks register twice, causing duplicate email dispatches or duplicate database inserts.
The Fix: Constructors must strictly assign dependencies to properties (public function __construct(private Logger $logger) {}), while hook bindings are delegated to a HookSubscriberInterface or explicit register() method.
The Mechanism: Adding private static ?self $instance = null and public static function getInstance() across all 30 classes in a plugin.
Why Developers Do It: Treating Singleton as a convenient proxy for global variables (global $my_service) to access objects from anywhere.
Production Consequence: Conceals real dependencies (Service Locator / Tight Coupling antipattern). Makes it impossible to swap a payment gateway with a test double (Mock) without modifying application code, while shared singleton state leaks across tests, causing flaky test suites.
The Fix: Maintain a single root ServiceContainer instance at the plugin composition root and design internal services as standard final readonly classes receiving dependencies via constructor parameters.
The Mechanism: Bootstrapping all 50 plugin modules (CSV importer, PDF generator, admin settings, Stripe webhooks) immediately on plugins_loaded, even when serving a public blog post or lightweight REST endpoint.
Why Developers Do It: Registering hooks via add_action('admin_menu', [new HeavyAdminModule($db, $api), 'render']) forces premature instantiation.
Production Consequence: Every single incoming HTTP request consumes 15–30 MB of excess RAM and tens of CPU milliseconds instantiating object graphs where 95% of components are never executed.
The Fix: Adopt the Lazy Hook Subscriber pattern (see registerSubscriber in our production artifact), where the hook map is inspected statically ($class::getSubscribedHooks()) and $this->get($subscriberClass) is invoked strictly when the event triggers.
The Mechanism: Scattering calls to global $wpdb; $wpdb->get_results(...) and get_option() across core business calculation classes.
Why Developers Do It: Quick database access without architecting repository abstraction layers.
Production Consequence: Modifying storage schemas (such as migrating from wp_postmeta to custom tables or Redis) forces invasive refactoring across dozens of business classes. Testing domain rules requires provisioning live MySQL databases.
The Fix: Encapsulate database interactions behind a domain repository interface (CaseStudyRepositoryInterface) and inject it into domain services via the DI container.
Define clean interfaces for external dependencies: loggers, database repositories, HTTP clients, and caching layers.
The domain layer depends strictly on abstractions, never on WordPress procedural core functions.
Implement business logic within immutable classes using Constructor Property Promotion and strict typing.
Zero add_action() calls or global variables inside constructors.
Create adapter classes with static getSubscribedHooks() mapping WordPress hooks to service methods.
Enables reading event maps without instantiating objects in memory.
Register interface bindings and lazy subscribers inside the container on plugins_loaded.
ReflectionClass automatically resolves and injects dependencies upon first invocation.
Execute unit tests on domain services with mocked interfaces without loading WordPress or MySQL.
Enables hundreds of unit tests to complete in under one second inside CI/CD pipelines.