Об'єктно-орієнтована архітектура в розробці корпоративних плагінів WordPress на PHP 8.4 / 8.5 вирішує фундаментальне інженерне завдання: ізоляцію чистої доменної бізнес-логіки (Domain Layer) від процедурного глобального стану ядра WordPress ($wpdb, $wp_filter, $post).
У сучасному enterprise-стеку WordPress 7.0+ застарілий патерн «кожен клас — глобальний Singleton з реєстрацією хуків у конструкторі» повністю витіснений модульною архітектурою, що спирається на 4 стовпи:
final readonly class, асиметрична видимість public private(set) та Constructor Property Promotion): Класи сервісів оголошуються незмінними (readonly), отримують свої залежності суворо через типізований конструктор і не зберігають мутабельного глобального стану.ReflectionClass автоматично вирішує граф залежностей конструкторів та керує життєвим циклом об'єктів.HookSubscriberInterface / Event Dispatcher): Повне відділення конструювання об'єкта (__construct) від його підписки на події WordPress (add_action / add_filter). Це дозволяє інстанціювати будь-який сервіс в ізольованих PHPUnit-тестах за 1 мілісекунду без завантаження ядра WordPress.First-Class Callable Syntax — $subscriber->handle(...)): Нативна фіча PHP 8.1–8.4+, що дозволяє передавати типізовані посилання на методи в add_action() зі статичним аналізом сигнатур у PHPStan / Psalm на рівні Level 9.Архітектура плагіна будується за принципом цибулинної моделі (Hexagonal / Clean Architecture): ядро WordPress та система хуків виступають зовнішнім інфраструктурним адаптером, який викликає суворо типізовані доменні сервіси через шар підписників.
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()]
┌──────────────────────┴──────────────────────┐
▼ ▼
[Інфраструктурний адаптер] [Доменний сервіс (Readonly)]
WpMailGateway implements MailerInterface LeadScoringService(MailerInterface)
│ │
└──────────────────────┬──────────────────────┘
▼
[Підписник: LeadHookSubscriber]
getSubscribedHooks():
'save_post_lead' => $this->onLeadSaved(...)
│
▼
[Hook Dispatcher: Реєстрація у $wp_filter]Зіставлення абстрактних інтерфейсів (LoggerInterface, CacheInterface) з конкретними реалізаціями в конфігурації контейнера.
Читання сигнатур конструкторів через ReflectionClass та рекурсивне складання графа імутабельних readonly-сервісів.
Обхід класів, що реалізують HookSubscriberInterface, та читання їхньої декларативної карти подій без виклику бізнес-логіки.
Прив'язка методів до $wp_filter через First-Class Callables. Інстанціювання важких сервісів відкладається до спрацьовування хука.
Спрацьовування хука ядра викликає адаптер підписника, який передає очищені DTO в чистий доменний сервіс.
add_action() з конструкторів та впровадженню залежностей через інтерфейси, бізнес-логіка розрахунку знижок, скорингу лідів або генерації AI-промптів тестується на чистому PHPUnit за 0.2 секунди без запуску MySQL.WP_AI_Client на кожному хіті сторінки, а інстанціює їх суворо в момент фактичного спрацьовування цільового хука.HookDispatcher, будь-який обробник плагіна можна безпечно вимкнути або замінити через контейнер, не вдаючись до хаків з Reflection за глобальним $wp_filter.readonly class та асиметричної видимості public private(set) запобігає випадковій мутації конфігурації платіжного шлюзу або токенів авторизації сторонніми фільтрами.Зіставимо класичний процедурно-синглтонний підхід зі стандартом сучасної модульної архітектури на PHP 8.4+:
Нижче наведено production-ready архітектурний каркас на PHP 8.4+ (readonly класи, First-Class Callables, Reflection Auto-Wiring), який ви можете використовувати як ядро будь-якого корпоративного плагіна.
<?php
/**
* Plugin Name: Fullstack Agentic Lab — Enterprise DI Container & Hook Dispatcher
* Description: Еталонна ООП-архітектура плагіна на PHP 8.4+ з автовайрингом залежностей та 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. Контракт декларативного підписника подій WordPress.
*/
interface HookSubscriberInterface
{
/**
* Повертає карту хуків: ['hook_name' => 'methodName' | ['methodName', priority, acceptedArgs]]
*
* @return array<string, string|array{0: string, 1?: int, 2?: int}>
*/
public static function getSubscribedHooks(): array;
}
/**
* 2. Контракт інфраструктурного логера (для дотримання Dependency Inversion Principle).
*/
interface TelemetryLoggerInterface
{
public function recordEvent(string $event, array $context = []): void;
}
/**
* 3. Реалізація логера для середовища WordPress.
*/
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. Чистий доменний сервіс (0 викликів функцій ядра WordPress — 100% тестованість у 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. Підписник хуків (Адаптер між WordPress та Доменним Сервісом).
*/
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. Легковажний DI-контейнер з Reflection Auto-Wiring та диспетчером підписників.
*/
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;
}
/**
* Реєстрація підписника з використанням лінивих замикань (Lazy Hook Registration).
* Екземпляр підписника та його залежності створюються ТІЛЬКИ при фактичному виклику хука!
*
* @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
);
}
}
}
// Ініціалізація контейнера на хуку plugins_loaded
add_action('plugins_loaded', static function (): void {
$container = new ServiceContainer();
$container->bind(TelemetryLoggerInterface::class, WpErrorLogTelemetry::class);
$container->registerSubscriber(CaseStudyPublicationSubscriber::class);
});У чому суть помилки: Розробник пише add_action('init', [$this, 'register']) прямо в тілі методу __construct() свого класу.
Чому розробники так роблять: Бажання поєднати створення об'єкта та його запуск в один рядок new MyFeature().
Що відбувається в продакшені та при тестуванні: Порушується принцип єдиної відповідальності (SRP). Об'єкт неможливо створити в модульному тесті PHPUnit без негайного спрацьовування глобальної функції add_action(). Крім того, якщо в коді випадково буде створено другий екземпляр класу (наприклад, для виклику допоміжного методу), усі його хуки зареєструються повторно, що призведе до подвійного надсилання листів або дублювання записів у БД.
Як правильно: Конструктор повинен тільки призначати залежності властивостям (public function __construct(private Logger $logger) {}), а прив'язка до хуків виноситься в HookSubscriberInterface або метод register().
У чому суть помилки: Додавання private static ?self $instance = null та public static function getInstance() у кожен із 30 класів плагіна (репозиторії, логери, API-клієнти).
Чому розробники так роблять: Використання Singleton як заміни глобальним змінним (global $my_service) для доступу до об'єкта з будь-якої точки коду.
Що відбувається в продакшені: Приховуються реальні залежності класів (Service Locator / Tight Coupling). Неможливо підмінити платіжний шлюз тестовим стабом (Mock) без модифікації вихідного коду, а стан синглтона зберігається між тестами, викликаючи плаваючі гейзенбаги (Flaky Tests).
Як правильно: Використовувати єдиний екземпляр ServiceContainer на рівні точки входу плагіна, а всі внутрішні сервіси проєктувати як звичайні final readonly класи, що отримують залежності через конструктор.
У чому суть помилки: У головному файлі плагіна на хуку plugins_loaded створюються об'єкти всіх 50 модулів (імпорт CSV, генератор PDF, адмін-налаштування, інтеграція зі Stripe), навіть якщо користувач просто відкрив головну сторінку блогу або викликав легкий REST-ендпоінт.
Чому розробники так роблять: Реєстрація хуків вигляду add_action('admin_menu', [new HeavyAdminModule($db, $api), 'render']) вимагає попереднього виклику new HeavyAdminModule().
Що відбувається в продакшені: На кожен запит витрачається 15–30 МБ додаткової оперативної пам'яті та десятки мілісекунд CPU на конструювання графа об'єктів, 95% яких взагалі не будуть викликані в поточному життєвому циклі сторінки.
Як правильно: Використовувати патерн Lazy Hook Subscriber (див. метод registerSubscriber у нашому бойовому артефакті), де карта хуків читається статично ($class::getSubscribedHooks()), а сам об'єкт $this->get($subscriberClass) конструюється контейнером тільки в момент спрацьовування конкретної події.
У чому суть помилки: Розмазування викликів global $wpdb; $wpdb->get_results(...) та get_option() по десятках класів розрахунку бізнес-логіки.
Чому розробники так роблять: Швидкий доступ до бази даних без проєктування шару репозиторіїв.
Що відбувається в продакшені: При зміні схеми зберігання (наприклад, переході з wp_postmeta на кастомну таблицю або Redis) доводиться переписувати десятки бізнес-класів. Тестування логіки вимагає розгортання повноцінної тестової БД MySQL.
Як правильно: Інкапсулювати роботу з $wpdb за спеціалізованим інтерфейсом репозиторію (CaseStudyRepositoryInterface), впроваджуючи його в доменні сервіси через DI-контейнер.
Визначення чистих інтерфейсів для зовнішніх залежностей: логерів, репозиторіїв БД, HTTP-клієнтів та кешу.
Доменний шар залежить тільки від абстракцій, а не від функцій ядра WordPress.
Написання бізнес-логіки в незмінних класах із Constructor Property Promotion та суворою типізацією.
Жодних викликів add_action() або глобальних змінних усередині конструктора.
Створення класів-адаптерів зі статичним методом getSubscribedHooks(), що зв'язують хуки WP з методами сервісів.
Дозволяє читати карту подій без створення екземплярів класів у пам'яті.
Реєстрація біндінгів інтерфейсів та лінивих підписників на хуку plugins_loaded.
ReflectionClass автоматично вирішує та впроваджує залежності при першому виклику.
Запуск модульних тестів доменних сервісів із моками інтерфейсів без ініціалізації WordPress та MySQL.
Забезпечує виконання сотень тестів менш ніж за 1 секунду в CI/CD.