La arquitectura orientada a objetos en el desarrollo corporativo de plugins para WordPress sobre PHP 8.4 / 8.5 resuelve un reto de ingeniería fundamental: aislar la lógica de negocio pura del dominio (Domain Layer) del estado global procedimental del núcleo de WordPress ($wpdb, $wp_filter, $post).
En el ecosistema enterprise de WordPress 7.0+, el antipatrón obsoleto donde «cada clase es un Singleton global que registra hooks en su constructor» ha sido desterrado por una arquitectura modular asentada sobre cuatro pilares:
final readonly class, visibilidad asimétrica public private(set) y promoción de propiedades en constructor): Las clases de servicio se declaran de solo lectura (readonly), reciben sus dependencias estrictamente por constructor y no conservan estado global mutable.ReflectionClass para resolver automáticamente el grafo de dependencias de constructores y gestionar el ciclo de vida de los objetos.HookSubscriberInterface / Event Dispatcher): Separación tajante entre la instanciación del objeto (__construct) y su suscripción a eventos de WordPress (add_action / add_filter). Esto permite instanciar cualquier servicio en suites de pruebas unitarias PHPUnit en 1 milisegundo sin cargar el núcleo de WordPress ni bases de datos.$subscriber->handle(...)): Característica nativa de PHP 8.1–8.4+ que permite pasar referencias tipadas a métodos en add_action(), verificadas por análisis estático (PHPStan / Psalm) en Level 9.Conciba la arquitectura del plugin bajo el prisma de la Arquitectura Hexagonal / Limpia: el núcleo de WordPress y el subsistema de hooks operan como adaptadores de infraestructura externos que invocan servicios de dominio estrictamente tipados mediante capas de suscriptores.
Contenedor DI PSR-11 Hook Subscriber Pattern First-Class Callable Asymmetric Visibility Brain Monkey[Bootstrap: agentic-lab-plugin.php]
│
▼
[PSR-11 Auto-Wiring Container::boot()]
┌──────────────────────┴──────────────────────┐
▼ ▼
[Adaptador de Infraestructura] [Servicio de Dominio (Readonly)]
WpMailGateway implements MailerInterface LeadScoringService(MailerInterface)
│ │
└──────────────────────┬──────────────────────┘
▼
[Suscriptor: LeadHookSubscriber]
getSubscribedHooks():
'save_post_lead' => $this->onLeadSaved(...)
│
▼
[Hook Dispatcher: Registro en $wp_filter]Mapeo de interfaces abstractas (LoggerInterface, CacheInterface) a implementaciones concretas en la configuración del contenedor.
Inspección de constructores mediante ReflectionClass para ensamblar recursivamente el árbol de servicios readonly inmutables.
Exploración de clases que implementan HookSubscriberInterface y lectura de sus mapas de eventos sin ejecutar lógica de dominio.
Vinculación de closures a $wp_filter con First-Class Callables. La instanciación de servicios pesados se pospone hasta que el hook se dispara.
La ejecución del hook invoca el suscriptor, que desempaqueta DTOs limpios y los entrega al servicio puro de dominio.
add_action() de los constructores e inyectar dependencias por interfaz, la lógica de cálculo de precios, scoring de leads o prompts de IA se prueba en PHPUnit en 0.2s sin MySQL.WP_AI_Client en cada petición HTTP, instanciándolos exclusivamente cuando el hook objetivo se dispara en el ciclo de vida.HookDispatcher gobernado, cualquier manejador puede desactivarse o reemplazarse de manera limpia desde el contenedor, sin recurrir a reflexiones complejas sobre $wp_filter.readonly class y visibilidad asimétrica public private(set) impide la mutación accidental de credenciales de pasarelas de pago o tokens de acceso por parte de filtros de terceros.Contraste el paradigma clásico procedimental basado en singletons frente al estándar modular contemporáneo en PHP 8.4+:
A continuación se presenta un armazón arquitectónico para entornos de producción en PHP 8.4+ (clases readonly, First-Class Callables, Auto-Wiring por Reflection) listo para utilizarse como núcleo de cualquier plugin de misión crítica.
<?php
/**
* Plugin Name: Fullstack Agentic Lab — Enterprise DI Container & Hook Dispatcher
* Description: Arquitectura de plugin POO de referencia en PHP 8.4+ con auto-wiring y 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. Contrato declarativo de suscriptor de eventos de WordPress.
*/
interface HookSubscriberInterface
{
/**
* Retorna el mapa de hooks: ['hook_name' => 'methodName' | ['methodName', priority, acceptedArgs]]
*
* @return array<string, string|array{0: string, 1?: int, 2?: int}>
*/
public static function getSubscribedHooks(): array;
}
/**
* 2. Contrato de logger de telemetría de infraestructura (Principio de Inversión de Dependencias).
*/
interface TelemetryLoggerInterface
{
public function recordEvent(string $event, array $context = []): void;
}
/**
* 3. Implementación de logger para el entorno de 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. Servicio de dominio puro (0 llamadas a funciones de WordPress — 100% comprobable en 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. Suscriptor de Hooks (Adaptador entre WordPress y los Servicios de Dominio).
*/
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. Contenedor DI ligero PSR-11 con Auto-Wiring por Reflexión y Despacho Perezoso.
*/
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;
}
/**
* Registro de suscriptor con closures perezosas (Lazy Hook Registration).
* ¡La instancia del suscriptor se genera ÚNICAMENTE cuando el hook se activa!
*
* @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
);
}
}
}
// Inicialización del contenedor en plugins_loaded
add_action('plugins_loaded', static function (): void {
$container = new ServiceContainer();
$container->bind(TelemetryLoggerInterface::class, WpErrorLogTelemetry::class);
$container->registerSubscriber(CaseStudyPublicationSubscriber::class);
});En qué consiste el error: Un programador introduce add_action('init', [$this, 'register']) directamente en el cuerpo del método __construct().
Por qué lo hacen: Deseo de unificar la asignación de memoria y el arranque en una sola llamada new MyFeature().
Qué sucede en producción y pruebas: Se transgrede el Principio de Responsabilidad Única (SRP). La clase no puede instanciarse en un test unitario de PHPUnit sin contaminar el estado global de WordPress. Además, si accidentalmente se crea una segunda instancia del objeto, todos los hooks se registrarán por duplicado, disparando envíos dobles de correos o duplicados en base de datos.
Cómo resolverlo: El constructor debe limitarse a asignar dependencias a propiedades (public function __construct(private Logger $logger) {}), delegando el registro de hooks a HookSubscriberInterface o a un método explícito register().
En qué consiste el error: Añadir private static ?self $instance = null y public static function getInstance() a todas y cada una de las 30 clases del plugin.
Por qué lo hacen: Tratar el patrón Singleton como sustituto de variables globales (global $mi_servicio) para acceder a objetos desde cualquier punto.
Qué sucede en producción: Oculta dependencias reales (antipatrón Service Locator / Acoplamiento fuerte). Impide sustituir pasarelas de pago por dobles de prueba (Mocks) sin alterar código fuente y conserva estado residual entre pruebas unitarias, provocando tests inestables (Flaky Tests).
Cómo resolverlo: Mantenga una única instancia de ServiceContainer en la raíz de arranque del plugin y modele los servicios internos como clases estándar final readonly que reciben dependencias por constructor.
En qué consiste el error: Crear instancias de los 50 módulos del plugin (importador CSV, generador PDF, panel de administración, webhooks de Stripe) en plugins_loaded, incluso al servir una simple entrada de blog o un endpoint REST ligero.
Por qué lo hacen: Registrar hooks al estilo add_action('admin_menu', [new HeavyAdminModule($db, $api), 'render']) fuerza la instanciación anticipada.
Qué sucede en producción: Cada visita HTTP desperdicia entre 15 y 30 MB de RAM y ciclos de CPU instanciando grafos de objetos de los cuales el 95% jamás será ejecutado en esa petición.
Cómo resolverlo: Aplique el patrón Lazy Hook Subscriber (véase registerSubscriber en el artefacto), donde la matriz de hooks se examina de forma estática ($class::getSubscribedHooks()) y $this->get($subscriberClass) se invoca estrictamente cuando el hook entra en acción.
En qué consiste el error: Diseminar llamadas a global $wpdb; $wpdb->get_results(...) y get_option() a lo largo de clases dedicadas a lógica de negocio.
Por qué lo hacen: Acceso rápido a datos sin modelar una capa de repositorios.
Qué sucede en producción: Modificar el almacenamiento (por ejemplo, migrar de wp_postmeta a tablas a medida o Redis) obliga a refactorizar decenas de clases. Comprobar la lógica requiere desplegar instancias reales de bases de datos MySQL.
Cómo resolverlo: Encapsule el acceso a base de datos tras una interfaz de repositorio (CaseStudyRepositoryInterface) e inyéctela en los servicios mediante el contenedor DI.
Definición de interfaces para dependencias externas: loggers, repositorios de BD, clientes HTTP y caché.
La capa de dominio depende estrictamente de abstracciones, jamás de funciones globales de WordPress.
Desarrollo de lógica de negocio en clases inmutables con Constructor Property Promotion y tipado estricto.
Cero llamadas a add_action() o variables globales dentro del constructor.
Creación de adaptadores con getSubscribedHooks() estático que asocian hooks de WP con métodos del servicio.
Permite leer el mapa de eventos sin instanciar objetos en memoria.
Registro de dependencias y suscriptores perezosos en el hook plugins_loaded.
ReflectionClass resuelve e inyecta dependencias automáticamente en la primera invocación.
Ejecución de pruebas unitarias sobre servicios de dominio con interfaces simuladas sin MySQL ni WP.
Permite ejecutar cientos de tests en menos de un segundo en pipelines CI/CD.