Cuando el cliente llegó con el encargo, la petición parecía sencilla: "Queremos modernizar la página donde nuestras delegaciones publican las fichas de demostración de maquinaria." La web actual era un Joomla de 2014 con complementos sin actualizar, una interfaz que nadie entendía y un servidor que rezaba para no caerse. Tres delegaciones regionales, un puñado de fichas activas en cualquier momento dado, y un administrador en central que quería tener visibilidad de todo sin depender de nadie para el día a día.

Lo que vino después fue una de esas reuniones donde el alcance va creciendo diapositiva a diapositiva y terminas saliendo con cuatro páginas de notas y la certeza de que "sencillo" era solo el punto de partida.

El problema real detrás de la migración

Grupo Nexo Industrial gestiona demostraciones de maquinaria pesada a través de sus delegaciones en tres provincias. Cada delegación tiene su propio responsable comercial, su propia sala de exposición y sus propios horarios de visita. El proceso era manual: mandaban un correo a central, alguien lo subía al Joomla con credenciales compartidas —sí, compartidas— y si había que editar algo se abría una solicitud interna.

El volumen no es enorme. En un momento dado puede haber unas diez fichas activas a la vez. Pero el proceso era un cuello de botella constante, y las fichas no se daban de baja solas: había páginas de máquinas vendidas meses atrás que seguían indexadas y recibiendo llamadas.

El objetivo: cada delegación gestiona sus propias fichas de forma autónoma, con total separación entre ellas. Central ve todo y tiene control total. El portal público muestra las fichas activas a compradores potenciales sin que tengan que registrarse.

La decisión tecnológica: PHP puro, sin disculpas

El debate interno duró una mañana. Los argumentos para meter un marco de trabajo moderno eran los habituales: velocidad de desarrollo, ecosistema, "ya lo conocemos todos". Los argumentos en contra eran más específicos y terminaron ganando.

El cliente tiene un servidor Plesk compartido. No hay acceso a la línea de comandos en producción, no hay Composer disponible, y el plan de alojamiento no admite configuraciones fuera de lo habitual. El volumen de tráfico es bajo y predecible. La lógica de negocio es directa: publicar fichas, que caduquen solas, controlar quién puede ver qué.

Acordamos: PHP 8 con PDO, MySQL/MariaDB, sin librerías externas, sin Composer en producción. La única dependencia es PHPMailer, instalada manualmente en vendor/. El CSS lo escribimos nosotros. Las fuentes están alojadas en nuestro propio servidor en assets/fonts/ en formato .woff2. Sin Google Fonts, sin redes de distribución externas, sin depender de servicios de terceros para mostrar una página.

No es una postura ideológica. En este contexto específico, un sistema ligero y explícito es más fácil de mantener, desplegar y depurar que un marco de trabajo con su capa de abstracción encima de otra capa de abstracción. El JavaScript es puro y al mínimo. Solo donde hace falta.

Modelo de datos: pensar antes de teclear

El responsable de base de datos se puso a trabajar mientras diseño hacía los bocetos de pantalla. Lo más importante era definir bien el modelo de roles y la separación de datos antes de escribir una sola línea de PHP.

Esquema de tablas principales
delegaciones     (id, nombre, slug, email, telefono, direccion, web, logotipo, activa)
salas            (id, delegacion_id, nombre)
lugares_entrega  (id, nombre, direccion, google_maps_url, created_at)
usuarios         (id, delegacion_id, email, password_hash, rol, activo)
fichas           (id, delegacion_id, sala_id, nombre, slug, ficha_img,
                  inicio_visita, fin_visita, lugar_entrega, hora_entrega,
                  horario_visita JSON, sin_visita, sin_entrega,
                  publicar_desde, publicar_hasta, imagen_generada,
                  created_at, updated_at)
historial_fichas (id, ficha_id, ...)
visitas          (id, ficha_id, ip_hash, fecha)
log_auditoria    (id, usuario_id, rol, accion, detalle, ip_hash, fecha)
password_resets  (id, usuario_id, token_hash, expira_en)
configuracion    (clave, valor)

La tabla configuracion es un almacén de clave y valor para todo lo que el administrador necesita ajustar sin que nosotros tengamos que desplegar nada: credenciales SMTP, identificadores de analítica, textos de cabecera, si el portal está en modo público o en modo de pruebas.

La decisión de tener un historial_fichas separado fue debatida. Al final lo justificamos por dos razones: auditoría limpia de lo que estuvo publicado y cuándo, y no contaminar la tabla activa con registros caducados que ralentizaran las consultas más frecuentes.

Roles y separación de datos: la parte que no se puede dejar para después

Tres niveles de acceso, cada uno con sus restricciones verificadas en el servidor, no solo en la interfaz.

Rol Acceso Restricción
Visitante público Listado de fichas activas de todas las delegaciones Sin cuenta, sin filtros, sin buscador
Responsable de delegación Panel propio: crear, editar y eliminar sus fichas Separación por delegacion_id en cada consulta SQL
Administrador Vista completa de todo el sistema Gestión global, registros de auditoría, configuración SMTP

El aislamiento está en cada consulta SQL, no solo en los menús que se muestran. Si alguien manipula la dirección web, el servidor comprueba delegacion_id antes de hacer nada. El sistema de autenticación usa sesiones PHP nativas con session_regenerate_id() tras iniciar sesión, bloqueo temporal tras cinco intentos fallidos por dirección IP y recuperación de contraseña con código de verificación único de una hora guardado cifrado en la base de datos.

La lógica de publicación automática

Este fue el punto más debatido en el equipo. Las fichas tienen que aparecer solas y desaparecer solas. Sin tareas programadas en un servidor Plesk compartido.

La solución: ejecutar la lógica de limpieza en cada petición a index.php. No es lo más elegante sobre el papel, pero en la práctica, con el volumen de este proyecto, funciona perfectamente y no añade tiempo de espera apreciable.

  1. Mostrar en portada: publicar_desde ≤ NOW() AND publicar_hasta > NOW() y que el período de visita no haya terminado sin acto de entrega pendiente.
  2. Mover a historial: cuando publicar_hasta < NOW() y la ficha aún no está en historial_fichas.
  3. Borrado definitivo a 30 días: eliminar de fichas, borrar la imagen WebP de uploads/, eliminar del historial.
  4. Limpiar auditoría: eliminar entradas de log_auditoria con más de 30 días.

El cálculo de publicar_hasta tiene su propia lógica: con actos públicos, caduca 60 minutos después del último acto. Si todo está marcado como privado, caduca a las 24 horas del inicio de publicación, con un tope absoluto de 48 horas. Esta lógica la calculamos también en el navegador con JavaScript para que el responsable vea en tiempo real cuándo va a caducar su ficha mientras la edita.

El formulario de fichas: los detalles que cuestan

El horario de visita fue el campo más complejo. Una ficha puede tener visitas en dos días distintos, con franja de mañana y tarde en cada uno. Decidimos guardar el horario como JSON en una columna horario_visita:

Estructura JSON del horario de visita
{
  "obs": "Acceso por puerta trasera. Aparcamiento gratuito.",
  "dias": [
    {
      "fecha": "2026-04-10",
      "man_desde": "09:00", "man_hasta": "13:00",
      "tar_desde": "15:00", "tar_hasta": "18:00"
    },
    {
      "fecha": "2026-04-11",
      "man_desde": "09:00", "man_hasta": "12:00",
      "tar_desde": "",      "tar_hasta": ""
    }
  ]
}

Los campos inicio_visita y fin_visita en la base de datos se derivan automáticamente de este JSON al guardar y se usan para la etiqueta "Visita Hoy" en portada, la lógica de caducidad y el cálculo de publicar_hasta. Si horario_visita contiene texto plano de fichas migradas del Joomla antiguo, se muestra como fila de texto sin interpretarlo.

El detalle que nos costó hora y media de depuración: el formulario del panel envía a /panel/ con la barra al final. Sin ella, Apache mod_dir hace una redirección 301 y pierde los datos del formulario. Ya no se le olvida a nadie del equipo.

Generación de imágenes: el motor GD

El responsable de delegación puede subir una imagen de la máquina en varios formatos (JPG, PNG, TIFF, PDF, WebP) o usar el diseñador integrado para generar una ficha visual. Todas las imágenes se convierten a WebP antes de guardarse.

  • Validación del tipo real del archivo con finfo_file(), nunca solo la extensión.
  • Conversión con Imagick si está disponible; como alternativa, GD para JPG/PNG.
  • Para TIFF y PDF: Imagick con Ghostscript (primera página del PDF).
  • Redimensionado a máximo 1200 píxeles de ancho manteniendo proporción.
  • Guardado con nombre aleatorio hexadecimal en uploads/fichas/.
  • La carpeta uploads/ tiene un .htaccess que bloquea la ejecución de PHP.

El diseñador integrado genera imágenes con GD puro, sin dependencias externas. Las fuentes TTF están incluidas en el propio proyecto. La imagen resultante es de 2480×3508 píxeles a 300 puntos por pulgada (formato A4 para impresión). El sistema ajusta el tamaño de la letra de forma dinámica si el nombre del equipo o el texto de descripción son largos: lo reduce de forma progresiva hasta que el contenido encaja sin solaparse.

Tres plantillas visuales: Blanco Clásico, Crema Vintage, Oscuro Corporativo. El logotipo de la delegación se centra en la cabecera con ajuste automático a un máximo de 180×60 píxeles.

Regeneración automática: si el responsable edita los horarios o el lugar de entrega de una ficha que tenía imagen generada, el motor regenera la imagen con los datos actualizados sin que el usuario tenga que hacer nada. La foto de la máquina se guarda por separado en uploads/fotos_maquinas/ para poder recuperarla en cada regeneración sin pedir que la vuelvan a subir.

Direcciones limpias y estructura de archivos

Las direcciones amigables se relacionan con los archivos PHP internos mediante mod_rewrite en Apache, sin exponer la estructura del proyecto:

Dirección web Archivo real
/index.php — portada + limpieza automática
/ficha/{id}ficha.php?slug=...
/delegacion/{id}delegacion.php?slug=...
/accesologin.php
/panel/panel/index.php
/admin/admin/index.php
/sitemap.xmlsitemap.php

Los identificadores de URL se generan al crear cada ficha: minúsculas, sin acentos, separados por guiones. En caso de coincidencia, se añade un sufijo numérico. Los directorios sensibles (admin/ y panel/) tienen su propio .htaccess que bloquea el acceso directo a los archivos PHP.

Integración de mapas sin coste

Los mapas de ubicación de las salas de exposición y los lugares de entrega se añaden pegando directamente una dirección de Google Maps en el perfil. Sin interfaces de programación externas, sin claves de acceso, sin coste. El sistema analiza la dirección de forma inteligente para extraer coordenadas o el lugar exacto, admitiendo formatos de ruta, búsqueda, destino, coordenadas directas, nombres de lugar y enlaces cortos de Google Maps.

Un detalle importante que añadimos tras la primera revisión con el cliente: los mapas y botones de navegación GPS solo son visibles mientras el acto no ha pasado. El mapa de visita desaparece tras el fin_visita y el de entrega tras la hora_entrega. Evita que alguien llegue a una delegación un martes buscando una máquina que ya fue entregada el jueves anterior.

Seguridad: lo que hay que hacer aunque nadie lo vea

Tomando como referencia la lista OWASP Top 10:

  • Solo consultas preparadas PDO con parámetros. Cero concatenación de variables en SQL.
  • Todo el contenido HTML pasa por htmlspecialchars() mediante la función h().
  • Código antifalsificación (CSRF) en todos los formularios, incluidas acciones sensibles como el vaciado de registros.
  • Cabeceras HTTP de seguridad: X-Frame-Options, X-Content-Type-Options, Referrer-Policy, X-XSS-Protection.
  • Política de seguridad de contenidos (CSP) implementada, permitiendo únicamente dominios de confianza.
  • La conexión PDO está envuelta en try-catch: si falla la base de datos, el usuario ve un mensaje genérico; las credenciales solo van al registro de errores del servidor.
  • El registro de auditoría anota inicios de sesión, edición de fichas, subidas de imagen, cambios de perfil y acciones de administración. Se limpia automáticamente a los 30 días.

Posicionamiento y compartir en redes sociales

El botón de WhatsApp en la ficha de cada máquina genera un enlace wa.me con el nombre del equipo y la dirección directa. Sin interfaz de programación externa, sin coste. Las metaetiquetas de redes sociales (Open Graph) se generan de forma dinámica: si no hay foto de ficha, se usa el logotipo de la delegación como alternativa solo para redes sociales; en la vista web, sin foto no se muestra nada.

El mapa del sitio se genera en tiempo real con Cache-Control: max-age=3600 e incluye la portada, las fichas activas y las páginas de cada delegación. El portal tiene un interruptor site_publico en la tabla configuracion: desactivado en las pruebas (inyecta noindex, nofollow en todas las páginas), activado en producción definitiva.

RGPD, cookies y avisos legales

El aviso de consentimiento aparece al primer acceso si el visitante no ha indicado su preferencia. Los botones "Aceptar" y "Rechazar" guardan la elección en el almacenamiento local del navegador. Matomo y GA4 solo se cargan en páginas públicas y únicamente cuando hay consentimiento. Los fragmentos de código de analítica son configurables desde el panel de administración sin tocar ningún archivo. La página legal recoge cuatro secciones ancladas: política de privacidad, aviso legal, accesibilidad y cookies.

Correo electrónico y notificaciones

PHPMailer se configura desde el panel de administración, sin tocar código. El responsable de delegación recibe un correo cada vez que crea o modifica una ficha: un resumen con los datos clave y un enlace directo. El administrador puede enviar correos individuales o masivos a los responsables desde su panel. La recuperación de contraseña funciona mediante un código de verificación único con validez de una hora, enviado al correo del perfil de la delegación.

Preguntas frecuentes

¿Por qué elegir PHP puro en vez de un marco de trabajo como Laravel o Symfony?

Cuando el servidor destino es un alojamiento compartido Plesk sin acceso a la línea de comandos, sin Composer en producción y con un volumen de tráfico bajo y predecible, un sistema ligero y explícito en PHP puro es más fácil de desplegar, mantener y depurar. La complejidad del proyecto no justificaba añadir capas de abstracción.

¿Cómo se gestionan las fichas que caducan si no hay tareas programadas en el servidor?

La lógica de limpieza se ejecuta en cada petición a index.php. Las fichas caducadas se mueven al historial y a los 30 días se eliminan definitivamente junto con sus imágenes. Con el volumen de este proyecto no añade latencia apreciable y evita depender de cron o de acceso a CLI en producción.

¿Es seguro un portal PHP sin marcos de trabajo?

Sí, siempre que se apliquen las medidas correctas: consultas preparadas PDO, escapado de todo el contenido HTML, tokens CSRF en todos los formularios POST, cabeceras HTTP de seguridad y validación del tipo real de los archivos subidos con finfo_file(). La seguridad no depende del marco de trabajo sino de cómo se escribe el código.

¿Qué es el diseñador de fichas integrado y cómo funciona?

Es un generador de imágenes en PHP usando la biblioteca GD, sin dependencias externas. Crea imágenes de 2480×3508 píxeles a 300 DPI con fuentes TTF incluidas en el proyecto. El responsable puede elegir entre tres plantillas visuales y el motor integra automáticamente los datos logísticos. Cuando se edita una ficha, la imagen se regenera sola.

¿Necesitas un portal a medida para tu empresa?

Si tienes un proceso interno que todavía vive en correos, hojas de cálculo compartidas o un gestor de contenidos que ya no da más de sí, puedo ayudarte a construir algo específico para tu flujo de trabajo, sin pagar licencias de software que no necesitas.