Caso de estudio técnico real de un portal de clientes con integraciones complejas: WordPress como capa de presentación, SQL Server como fuente de verdad para los datos de equipos, SharePoint para la gestión documental, Auth0 como SSO y una caché precomputada nocturna que hace que el portal cargue en milisegundos.
Este artículo es diferente a los que suelo publicar. En lugar de principios genéricos, cuenta la historia técnica de un sistema concreto: el portal de clientes de Jayme da Costa (JDC), una empresa industrial portuguesa con más de 80 años de trayectoria en equipos eléctricos y energía.
El portal gestiona miles de equipos distribuidos entre cientos de clientes corporativos. Cuando un cliente inicia sesión, ve únicamente sus propios equipos, con toda la documentación técnica relevante (certificados de prueba, manuales), y puede autorizar a usuarios adicionales dentro de su empresa. La documentación reside en SharePoint. Los datos de equipos residen en un almacén de datos SQL Server alimentado por los sistemas internos de JDC. La autenticación está federada mediante Auth0. Y todo esto funciona sobre WordPress.
Publico esto con la autorización expresa del cliente. Mostraré arquitectura real, código real y los problemas concretos que tuve que resolver, porque creo que las decisiones técnicas que tomé tienen valor para otros desarrolladores que se enfrentan a integraciones empresariales.
Por qué WordPress (y no algo «más serio»)
La primera pregunta que hace cualquier arquitecto cuando ve una propuesta como esta es: ¿por qué WordPress? Es una pregunta legítima. JDC contaba con un equipo de TI competente y podría haber encargado una aplicación .NET dedicada a una agencia especializada en proyectos empresariales.
La decisión se tomó en base a tres realidades:
Velocidad de entrega. Una aplicación .NET personalizada para esto habría costado varias veces más y habría tardado entre 6 y 12 meses adicionales. WordPress me permite alcanzar una versión funcional en semanas, no en meses.
Autonomía en la gestión de contenidos. El equipo de marketing de JDC necesita poder gestionar páginas institucionales, preguntas frecuentes y avisos sin tener que llamar a TI. WordPress ofrece esto de serie.
El trabajo real no está en la parte de WordPress. El sistema es, en esencia, un agregador de datos procedentes de otros sistemas. WordPress es la capa de presentación y gestión de usuarios. Las integraciones con SQL Server y SharePoint, la autenticación con Auth0, la lógica de permisos multicapa: ahí es donde reside la complejidad. Y esa complejidad es independiente de la plataforma.
La regla que aplico: WordPress es adecuado para portales con mucha lectura y peso editorial. No es adecuado para sistemas transaccionales con alto volumen de escritura o requisitos de latencia ultrabaja. El Portal JDC encaja claramente en el primer grupo.
La arquitectura, en tres capas
flowchart TB
subgraph Browser["Browser"]
UI["Vanilla JS
+ HTML/CSS"]
end
subgraph WP["WordPress + JDC Portal Plugin (PHP 8.3, Apache)"]
REST["REST API
31 endpoints"]
AUTH["Auth Layer
(Auth0 + Local)"]
CACHE[("Transients
+ MySQL")]
WARM["CacheWarmer
(nightly cron)"]
end
subgraph External["External Systems"]
AUTH0[("Auth0
OAuth 2.0")]
SQL[("SQL Server
Data Warehouse")]
SP[("SharePoint
Microsoft Graph")]
SMTP["SMTP"]
end
UI -->|"X-WP-Nonce"| REST
REST --> AUTH
REST --> CACHE
AUTH -.->|"OAuth flow"| AUTH0
REST -.->|"PDO + ODBC 18"| SQL
REST -.->|"OAuth client_credentials
+ token cache"| SP
REST -.->|"PHPMailer"| SMTP
WARM -->|"3am local"| SP
WARM -->|"populate"| CACHE
WordPress es el orquestador. No almacena datos de equipos ni documentos, únicamente:
- Identidad de usuario local (cuando el sistema crea cuentas a partir de Auth0)
- Tablas auxiliares:
wp_jdc_audit(registros),wp_jdc_tokens(invitaciones y restablecimientos de contraseña) - Caché transitoria de todo lo que proviene de los sistemas externos
Los sistemas externos son la fuente de verdad. Cuando un equipo de JDC añade equipamiento al ERP, o un técnico actualiza un certificado de prueba en SharePoint, el cambio se produce en esos sistemas. El portal refleja — no reemplaza.
Los 6 problemas técnicos que merecen contarse
1. Autenticación federada con mapeo de permisos por capas
JDC no quería que los clientes gestionasen otra contraseña. Querían SSO mediante Auth0, con la opción de que los clientes pudiesen integrar posteriormente su propio Proveedor de Identidad (IdP) —Microsoft Entra ID, Google Workspace, Okta— sin modificar una sola línea de mi código. Auth0 se encarga de esa abstracción.
El flujo es el estándar OAuth 2.0 Authorization Code, con PKCE y una cookie de estado como defensa contra CSRF. La parte interesante es lo que ocurre después del callback de Auth0:
// includes/Integrations/Auth0/CallbackHandler.php
private static function check_email_authorization(string $email): ?array {
global $wpdb;
// 1. Pending invite (master invited this email via portal)
$invite = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}jdc_tokens
WHERE email = %s AND type = 'INVITE'
AND used_at IS NULL AND expires_at > %s
ORDER BY id DESC LIMIT 1",
$email, gmdate('Y-m-d H:i:s')
), ARRAY_A);
if ($invite) { /* ... auto-onboard as regular user ... */ }
// 2. DW: contact authorised by the company in v_contacts
$contact = SqlServer::one(
"SELECT TOP 1 ClienteID, IsMaster
FROM integration.v_contacts
WHERE LOWER(ContactEmail) = LOWER(?)",
[$email]
);
if ($contact) { /* ... auto-onboard with computed role ... */ }
// 3. DW: company primary email in v_empresas
$company = SqlServer::one(
"SELECT TOP 1 CustomerNumber FROM integration.v_empresas
WHERE LOWER(Email) = LOWER(?) AND ISNULL(IsBlocked,0) = 0",
[$email]
);
if ($company) { /* ... auto-onboard as master ... */ }
return null; // not authorised
}
Existen tres fuentes de autorización, comprobadas en orden de prioridad. Un usuario cuyo correo electrónico no figure en ninguna de las tres será rechazado aunque disponga de una cuenta Auth0 válida.
La siguiente capa es la verificación continua. Incluso un usuario con una cuenta activa en el portal puede perder el acceso: ya sea por parte de JDC (estado revoked) o por el usuario maestro de su empresa (estado inactive). Auth::can_user_access() comprueba esto en cada solicitud:
public static function can_user_access(int $user_id): bool {
if ($user_id <= 0) return false;
$u = get_user_by('id', $user_id);
if (!$u) return false;
$roles = (array) $u->roles;
$is_wp_admin = in_array('administrator', $roles, true);
$is_jdc_admin = in_array('jdc_admin', $roles, true);
// WP admins always pass — they need a way back in to fix things.
// Don't mark your only sysadmin as jdc_admin-only.
if ($is_wp_admin) return true;
$status = (string) get_user_meta($user_id, 'jdc_access_status', true) ?: 'active';
if ($status !== 'active') return false;
if ($is_jdc_admin) return true; // jdc_admin has no expiry
// Regular users expire after 1 year
$expires = (string) get_user_meta($user_id, 'jdc_access_expires_at', true);
if ($expires !== '') {
$ts = strtotime($expires . ' UTC');
if ($ts !== false && time() > $ts) return false;
}
return true;
}
El comentario sobre «no marques a tu único administrador de sistema como exclusivamente jdc_admin» no está ahí por estética. Está ahí porque fue un problema real durante el desarrollo y, si no lo hubiera documentado, volvería a causarme problemas dentro de 6 meses cuando alguien —probablemente yo mismo— hiciera un cambio rápido y se quedara sin acceso.
2. Rendimiento de consultas en SQL Server: el problema invisible
La vista v_equipamentos tiene decenas de miles de filas. Cada cliente dispone de entre decenas y cientos de equipos. Listar los equipos de un cliente es una consulta sencilla, pero la propia tabla ya es de gran tamaño y se encuentra en un servidor remoto separado de WordPress por una VPN.
La primera tentación es cachear todo. Pero eso introduce inconsistencias: cuando JDC envía nuevos equipos a un cliente, ese cliente no puede esperar 24 horas para verlos en el portal. La regla de negocio establece que los datos del ERP deben aparecer en el portal en cuestión de minutos.
La solución reside en la disciplina de consultas:
// includes/Integrations/SqlServer/Client.php
public function listEquipmentsByCustomer(
string $customerNumber,
int $limit = 5000,
array $filters = []
): array {
$limit = max(1, min(10000, $limit)); // defensive bound
$where = ["e.CustomerNumber = ?"];
$params = [$customerNumber];
if (!empty($filters['serial'])) { /* LIKE ? with parameter binding */ }
if (!empty($filters['product'])) { /* LIKE across two columns */ }
if (!empty($filters['family'])) { /* equality */ }
$sql = "SELECT TOP {$limit} ...
FROM integration.v_equipamentos e
LEFT JOIN integration.v_familias f
ON f.FamID COLLATE Latin1_General_100_CI_AS
= e.FamID COLLATE Latin1_General_100_CI_AS
WHERE " . implode(' AND ', $where) . "
ORDER BY e.InvoiceDate DESC";
// ...
}
Tres aspectos que importan más de lo que parecen:
COLLATE Latin1_General_100_CI_AS en el JOIN. Cuando dos tablas de SQL Server tienen columnas con intercalaciones distintas (una sensible a mayúsculas y otra no), el JOIN puede fallar silenciosamente o recurrir a NESTED LOOP en lugar de HASH JOIN. Forzar una intercalación explícita soluciona este problema.
TOP {$limit} interpolado en lugar de enlazado como parámetro. Polémico. SQL Server no acepta TOP ? en consultas parametrizadas; debe ser un literal. La defensa contra inyección es max(1, min(10000, $limit)) en el punto de entrada, lo que garantiza que $limit sea siempre un entero entre 1 y 10.000.
ORDER BY InvoiceDate DESC. Sin esto, la paginación se vuelve no determinista. SQL Server no garantiza el orden sin un ORDER BY explícito, aunque parezca consistente en las pruebas.
3. SharePoint: de «60 segundos por panel» a «milisegundos»
Esta es la historia más interesante del proyecto.
La documentación técnica reside en dos bibliotecas de SharePoint: Boletins de Ensaios (control de calidad — un certificado de ensayo por equipo) y Manuais (manuales técnicos organizados por familia de producto). Cada equipo, al consultarse en el portal, muestra la documentación correspondiente.
Versión ingenua: cuando un usuario abre la página de un equipo, realizo 4-6 consultas a Microsoft Graph para encontrar los documentos relevantes. Cada consulta tarda entre 200 y 800 ms (latencia de red + procesamiento de Graph). Total: 2-5 segundos por equipo. Inaceptable.
Versión mejorada: caché por equipo. Si el mismo equipo se consulta dos veces, la segunda vez no accede a Graph. Esto funciona para usuarios recurrentes, pero el primer acceso del día es igualmente lento — y una lista maestra de equipos muestra docenas, cada uno de los cuales necesita precargar su documentación.
Versión final: el «banco».
Construí una estructura de datos precomputada que contiene toda la documentación visible, indexada de varias formas. Este «banco» se genera una vez por noche mediante un cron job y reside en un único transient de WordPress. Cada consulta individual se convierte en una operación O(1) en memoria.
flowchart LR
subgraph Night["3am — Nightly Cache Warmer"]
direction TB
START["Start"]
FLUSH["Flush volatile caches"]
DOCS["Build docs-index
(~5s)"]
BANK["Build bank
(~60-90s)"]
ADM["Pre-warm admin lists"]
END_["Audit log + status"]
START --> FLUSH --> DOCS --> BANK --> ADM --> END_
end
subgraph Day["During the day"]
REQ["Request: docs for equipment X"]
CHECK{"Bank exists
in transient?"}
FAST["O(1) lookup
~5ms"]
SLOW["Live fallback
~2-5s"]
REQ --> CHECK
CHECK -->|yes| FAST
CHECK -->|no| SLOW
end
BANK -.->|"set_transient
(2-day TTL)"| CHECK
El banco tiene la siguiente estructura:
boletins_by_serial[NumSerie] → [docs without file_url]
boletins_by_product[NumProduto] → [docs without file_url]
docs_by_folder_id[folder_id] → [docs with .url follows resolved]
folder_ids_by_product[product] → [folder_id, ...] (tier 1+2)
folder_ids_by_family[family_id] → [folder_id, ...] (tier 3)
El resultado: el primer acceso a cualquier equipo, desde cualquier cliente, cualquier día, es instantáneo. El coste es una ventana nocturna de 60-90 segundos en la que el servidor lo reconstruye todo.
Hay matices que merece la pena documentar:
Límite de memoria explícito durante la construcción. El banco puede alcanzar un pico de ~320 MB cuando todas las estructuras están en memoria antes de serializarse:
@ini_set('memory_limit', '512M');
@set_time_limit(300);
Escrituras de progreso con limitación de frecuencia. El CacheWarmer publica el progreso en la interfaz de administración mediante transients. Sin embargo, escribir en wp_options 105 veces durante la construcción (una por cada carpeta de SharePoint) es costoso. Solución: limitar a 0,5 s entre escrituras:
$last_push = 0.0;
$bank = $sp->bank_build(function(int $done, int $total, string $label) use ($start, &$last_push) {
$now = microtime(true);
if ($now - $last_push < 0.5 && $done < $total) return;
$last_push = $now;
self::set_progress(/* ... */);
});
Caché de tokens con TTL conservador. Los tokens OAuth de Microsoft Graph expiran tras 1 hora. Los almaceno en caché durante 58 minutos (ttl - 120s) para no acercarse nunca al límite y disponer de tiempo para renovarlos antes de que fallen. La caché reside en un transient para persistir entre peticiones:
self::$token_exp = time() + ((int)($body['expires_in'] ?? 3500) - 120);
set_transient('jdc_sp_token', self::$token, max(60, $ttl));
4. Trabajos cron de WordPress: el problema del pseudo-cron
El CacheWarmer se programa para ejecutarse cada noche a las 3:00 (hora local). Pero WordPress, por defecto, utiliza un sistema de «pseudo-cron» que solo se dispara cuando alguien visita el sitio. Esto significa que si nadie accede al portal entre las 3:00 y las 8:00, el calentador no se ejecuta — y el primer usuario de la mañana se encuentra con la versión en frío.
La solución es doble. En primer lugar, en producción deshabilito el pseudo-cron de WordPress en wp-config.php:
define('DISABLE_WP_CRON', true);
Y en el crontab del servidor:
*/5 * * * * curl -s https://suporte.jaymedacosta.pt/wp-cron.php?doing_wp_cron > /dev/null
Segundo problema: dentro de Docker, la función spawn_cron() de WordPress intenta realizar una petición HTTP al propio sitio usando la URL pública. Pero desde dentro del contenedor, esa URL pública no es accesible (localhost:8080 es el host visto desde el exterior; dentro, solo existe el puerto 80 de Apache local). La solución es un filtro:
add_filter('cron_request', function(array $req) {
$parsed = parse_url($req['url'] ?? '');
$qs = $parsed['query'] ?? '';
$req['url'] = 'http://127.0.0.1/wp-cron.php' . ($qs ? '?' . $qs : '');
$req['args']['sslverify'] = false; // nos comunicamos con nosotros mismos
return $req;
});
Pequeño detalle, pero resolvió tres horas de depuración en staging donde el cron fallaba silenciosamente.
5. Limitación de velocidad: defensa en múltiples capas
Los endpoints sensibles (login, restablecimiento de contraseña, incorporación de usuarios) necesitan limitación de tasa. WordPress no la proporciona de serie. La solución es sencilla pero eficaz — un servicio que utiliza transients como contador deslizante:
// includes/Services/RateLimiter.php
public static function hit(string $key, int $limit, int $window_seconds): array {
$now = time();
$data = get_transient($key);
if (!is_array($data) || empty($data['reset_at']) || (int)$data['reset_at'] <= $now) {
$data = ['count' => 0, 'reset_at' => $now + $window_seconds];
}
$data['count'] = (int)($data['count'] ?? 0) + 1;
set_transient($key, $data, $window_seconds);
return [
'allowed' => ((int)$data['count'] <= $limit),
'remaining' => max(0, $limit - (int)$data['count']),
'reset_in' => max(0, (int)$data['reset_at'] - $now),
];
}
public static function ip(): string {
// Order matters: Cloudflare > X-Forwarded-For > X-Real-IP > REMOTE_ADDR
$keys = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'REMOTE_ADDR'];
foreach ($keys as $k) {
if (!empty($_SERVER[$k])) {
$v = trim(explode(',', (string) $_SERVER[$k])[0]);
if ($v !== '') return $v;
}
}
return '0.0.0.0';
}
Cada endpoint sensible llama a RateLimiter::hit() con una clave compuesta (acción + IP + email). Si se supera el límite, devuelve 429 con una cabecera Retry-After. El detalle en RateLimiter::ip() — leer CF-Connecting-IP antes que X-Forwarded-For — es importante porque el sitio se encuentra detrás de Cloudflare; sin esto, todos los usuarios compartirían la misma IP del nodo perimetral de Cloudflare y la limitación de tasa bloquearía a todos al bloquear a uno.
6. Multitenencia: la paranoia que mereció la pena
El error más grave posible en este sistema sería que un cliente pudiera ver los datos de otro cliente. No importa cuántas optimizaciones haga si esto ocurre una sola vez.
La defensa está estructurada en capas:
Capa 1: a nivel de consulta. Cada consulta SQL que devuelve datos de equipos tiene CustomerNumber = ? en la cláusula WHERE. Sin excepciones. Sin «casi excepciones». Este predicado forma parte de la propia consulta, no se añade mediante código posterior:
public function getEquipmentBySerialForCustomer(
string $customerNumber,
string $serial
): ?array {
$sql = "SELECT TOP 1 ... WHERE e.CustomerNumber = ? AND e.SerialNumber = ?";
// ...
}
Existe una versión separada para administradores (getEquipmentBySerial) sin el filtro de cliente — y esta solo es invocada por endpoints con un permission_callback que verifica Auth::is_admin().
Capa 2: nivel de caché. El banco de SharePoint es compartido entre todos los usuarios. Pero el banco no contiene file_urls reales, solo doc_keys. La URL de archivo real se construye por equipo en proxy_url($file_base, $serial, $doc_key), y el endpoint que sirve el archivo verifica que el usuario tiene acceso al serial solicitado antes de hacer proxy hacia Graph.
Capa 3: registro de auditoría. Cada acceso a un documento o equipo genera una fila en wp_jdc_audit. Incluso si algo escapa a las capas 1 y 2, el registro me permite detectar y responder. Tras 6 meses en producción: cero incidentes.
Lo que aprendí de este proyecto
WordPress es una elección defendible para portales corporativos cuando el diseño lo refleja. El Portal JDC no es «WordPress con plugins». Es una aplicación personalizada que utiliza WordPress como capa de alojamiento, autenticación, administración y gestión de contenidos. La mayor parte de la lógica reside en clases PHP organizadas con espacios de nombres, patrones SOLID y sin dependencia de plugins de terceros para la funcionalidad principal.
Las cachés precomputadas superan a las cachés reactivas en sistemas con muchas lecturas. El salto de «el primer acceso es lento, los siguientes rápidos» a «todos los accesos son rápidos» cambió la percepción del producto. Coste: entre 60 y 90 segundos por noche y ~50 MB de transient. Beneficio: latencia mediana inferior a 100 ms en todas las vistas.
La defensa en capas sobrevive a los errores. La combinación de filtrado a nivel de consulta, alcance de caché y registro de auditoría hace que cualquier error en una capa sea detectado por la siguiente. En sistemas multitenant, esto no es paranoia: es el estándar profesional mínimo.
Los pequeños detalles importan más de lo que parecen. El comentario sobre DISABLE_WP_CRON, el filtro cron_request para Docker, el orden de las cabeceras en RateLimiter::ip(), el COLLATE explícito en el JOIN: cada uno de estos detalles representa horas de depuración que no se repiten porque quedaron documentados en el código donde alguien los encontrará cuando los necesite.
Stack final, resumido
| Componente | Elección | Motivo |
|---|---|---|
| CMS / framework | WordPress 6.8+ + plugin personalizado | Velocidad de entrega, gestión de contenidos, ecosistema |
| Lenguaje backend | PHP 8.3 | Tipos estrictos, argumentos nombrados, rendimiento |
| Capa de alojamiento | Apache 2.4 + Docker | Stack estándar, Dockerfile reproducible |
| Identidad | Auth0 (OAuth 2.0) | Federación flexible, soporte multi-IdP |
| Fuente de verdad (datos) | SQL Server (DW) vía PDO + ODBC 18 | Donde JDC ya almacena los datos |
| Fuente de verdad (documentos) | SharePoint vía Microsoft Graph API | Donde JDC ya almacena los documentos |
| Caché | WP Transients (MySQL) + memoria PHP | Simplicidad, sin stack adicional |
| Correo electrónico | PHPMailer + SMTP corporativo | Estándar de WordPress |
| Frontend | Vanilla JS + propiedades CSS personalizadas | Sin paso de compilación, sin dependencia de framework |
| i18n | Sistema personalizado (4 idiomas) | Control total sobre claves y valores de respaldo |
Nada exótico aquí. Son decisiones conservadoras, justificadas y sostenibles a cinco años vista.
Si tienes un proyecto similar —un sistema corporativo con integraciones complejas pero requisitos pragmáticos— y quieres hablar sobre arquitectura, contáctame directamente. Me cuesta 30 minutos valorar si merece la pena explorarlo, y obtendrás un diagnóstico técnico honesto independientemente de si acabamos trabajando juntos.