Saltar al contenido principal
Login

Construyendo el futuro: Una guía práctica para el desarrollo de entidades personalizadas en Drupal 11.2

Andrii Kocherhin profile picture
por Andrii Kocherhin
Cyber Drupal 11

Drupal 11.2.x no es solo “otro lanzamiento puntual”. Es el momento donde las expectativas modernas de Drupal se endurecen:

  • PHP 8.3+ es la línea base.
  • Hooks OO con atributos reemplazan las implementaciones tradicionales de hooks en .module.
  • Restricciones de validación proporcionan mejor UX que lanzar excepciones.
  • SDC es un flujo de trabajo de tematización de primera clase.
  • Estrategias de caché apropiadas son esenciales para contenido basado en tiempo.

Este tutorial construye Promo Kit (promo_kit)—un módulo completo de entidad de contenido personalizada con arquitectura moderna de Drupal 11.2:

Entidad de contenido personalizada (promo_kit_promo) con operaciones CRUD completas

Campos base (imagen/enlace/rango de fechas/estilo) definidos en código

Servicio PromoManager (filtrado basado en consultas para promos activos)

Plugin de bloque (muestra promos activos con caché inteligente)

Restricciones de validación (UX apropiado de validación de formularios)

Hooks OO (implementaciones modernas de hooks basadas en atributos)

Tematización SDC (Single Directory Components para frontend)


0. Lo que estamos construyendo

Requisito

Los mercadólogos necesitan banners promocionales que aparezcan en el sitio.

  • Campos: Etiqueta, Descripción, Imagen del banner, Enlace, Estilo (Alert/Info/Party), Horario (rango de fechas de inicio/fin)
  • Lógica: La promo está activa si:
    • estado = publicado (habilitado)
    • inicio está vacío o inicio ≤ ahora
    • fin está vacío o fin > ahora
  • Frontend: Renderizado por un Single Directory Component (SDC) dedicado
  • Validación: La fecha de fin no puede ser anterior a la fecha de inicio (validado vía restricción)

Arquitectura

  • Entidad de contenido personalizada: promo_kit_promo (no Block Content)
  • Campos base: Definidos en Promo::baseFieldDefinitions()
  • Servicio PromoManager: Maneja consultas de promos activos con filtrado apropiado de fechas
  • Plugin de bloque: PromoBannerBlock muestra promos activos
  • Restricción de validación: PromoDateRangeConstraint valida lógica de fechas
  • Hooks OO: Clase PromoHooks para validación presave de entidad
  • Componente SDC: promo_banner para tematización

1. Entorno: Drupal 11 estricto y moderno (PHP 8.3+)

Drupal 11 es estricto sobre PHP moderno. Si tu stack local es “suficientemente cercano”, no es suficiente.

Instalar DDEV (Homebrew)

bash
# MacOS / Linux (Homebrew)
brew install ddev/ddev/ddev

Crear el proyecto Drupal 11

bash
composer create-project drupal/recommended-project:^11 promo-site
cd promo-site

ddev config --project-type=drupal11 --docroot=web --php-version=8.3
ddev start

ddev composer require drush/drush:^13
ddev drush site:install -y

2. Módulo Promo Kit: Andamiaje

Generar el módulo:

bash
ddev drush generate module

Usar prompts como:

  • Nombre del módulo: Promo Kit
  • Nombre de máquina: promo_kit
  • Descripción: Gestiona banners promocionales con integración SDC.
  • Dependencias: datetime_range, link, file
  • ¿Crear archivo .module? (lo usaremos para hooks de tema)
  • ¿Crear archivo install? (opcional)

Editar web/modules/custom/promo_kit/promo_kit.info.yml:

yaml
name:'Promo Kit'
type: module
description:'Gestiona banners promocionales con integración SDC.'
package: Custom
core_version_requirement: ^10 || ^11
dependencies:
- datetime_range:datetime_range
- file:file
- link:link

3. Entidad de contenido personalizada: Banner promocional

Crear la clase de entidad:

web/modules/custom/promo_kit/src/Entity/Promo.php

Esta entidad extiende EditorialContentEntityBase que proporciona:

  • Revisiones
  • Flujo de trabajo de publicación (campo de estado)
  • Soporte de traducción
  • Integración de moderación

Características clave:

  • ID de entidad: promo_kit_promo (con prefijo para evitar colisiones)
  • Campos base: label, status, description, date_range, link, image, style
  • Tablas: promo_kit_promo, promo_kit_promo_field_data, promo_kit_promo_revision, promo_kit_promo_field_revision

La entidad usa atributos PHP 8 para configuración:

php
#[ContentEntityType(
  id: 'promo_kit_promo',
  label: new TranslatableMarkup('Banner promocional'),
  // ... manejadores, enlaces, etc.
)]

Campos base incluyen:

  • label: Título/nombre de la promo
  • status: Publicado/no publicado
  • description: Descripción de texto largo
  • promo_date_range: Campo de rango de fechas (inicio/fin)
  • promo_link: Campo de enlace
  • promo_image: Campo de imagen
  • promo_style: Campo de lista (info/alert/party)
  • uid: Referencia al propietario
  • created/changed: Marcas de tiempo

4. Servicio PromoManager: Filtrado inteligente basado en consultas

Crear el servicio:

web/modules/custom/promo_kit/src/PromoManager.php

Este servicio proporciona:

4.1 Método getActivePromos()

Devuelve promos activos empujando el filtrado de fechas a la consulta de entidad (no cargando todas y filtrando en PHP):

php
public function getActivePromos(): array {
  $now = new DrupalDateTime('now', 'UTC');
  $now_string = $now->format('Y-m-d\\TH:i:s');

  $storage = $this->entityTypeManager->getStorage('promo_kit_promo');
  $query = $storage->getQuery();

  $query->accessCheck(TRUE)
        ->condition('status', 1);

  // Lógica de filtrado de fechas con grupos OR/AND
  // ...

  $ids = $query->execute();
  return $storage->loadMultiple($ids);
}

Puntos clave:

  • Usa zona horaria UTC (Drupal almacena fechas en UTC)
  • Construye condiciones de consulta complejas para rangos de fechas
  • Maneja campos de fecha vacíos (siempre activo si no hay fechas establecidas)
  • Escalable: filtra a nivel de base de datos, no en PHP

4.2 Método getSecondsUntilNextBoundary()

Calcula cache max-age dinámico basado en cuándo la próxima promo inicia o termina:

php
public function getSecondsUntilNextBoundary(): int {
  // Encontrar la fecha de inicio/fin futura más cercana
  // Devolver segundos hasta ese límite
  // Por defecto: 3600 (1 hora)
}

Esto asegura que el caché del bloque expire en el momento correcto cuando cambia la visibilidad de la promo.

Registrar el servicio en promo_kit.services.yml:

yaml
services:
promo_kit.manager:
class: Drupal\\promo_kit\\PromoManager
arguments:['@entity_type.manager']

5. Plugin de bloque: Mostrar promos activos

Crear el plugin de bloque:

web/modules/custom/promo_kit/src/Plugin/Block/PromoBannerBlock.php

Este bloque:

  • Inyecta PromoManager y EntityTypeManagerInterface
  • Llama $this->promoManager->getActivePromos()
  • Renderiza cada promo usando el view builder
  • Implementa caché apropiado:
  • Etiquetas de caché: promo_kit_promo_list (invalida cuando cualquier promo cambia)
  • Contextos de caché: user.permissions (verificación de acceso afecta resultados)
  • Cache max-age: Dinámico basado en el próximo límite
php
32 lines
#[Block(
  id: "promo_banner_block",
  admin_label: new TranslatableMarkup("Banners promocionales"),
  category: new TranslatableMarkup("Promo Kit")
)]
class PromoBannerBlock extends BlockBase implements ContainerFactoryPluginInterface {

  public function build(): array {
    $active_promos = $this->promoManager->getActivePromos();

    if (empty($active_promos)) {
      return [];
    }

    $view_builder = $this->entityTypeManager->getViewBuilder('promo_kit_promo');

    $build = [
      '#theme' => 'item_list',
      '#items' => [],
    ];

    foreach ($active_promos as $promo) {
      $build['#items'][] = $view_builder->view($promo, 'default');
    }

    return $build;
  }

  public function getCacheMaxAge(): int {
    return $this->promoManager->getSecondsUntilNextBoundary();
  }
}

6. Restricción de validación: Lógica de rango de fechas

En lugar de lanzar excepciones en presave (que causa pantallas blancas), usa una restricción de validación para mostrar errores de formulario apropiados.

6.1 Plugin de restricción

web/modules/custom/promo_kit/src/Plugin/Validation/Constraint/PromoDateRangeConstraint.php

php
#[Constraint(
  id: 'PromoDateRange',
  label: new TranslatableMarkup('Rango de fechas de promo', [], ['context' => 'Validation']),
  type: ['entity:promo_kit_promo']
)]
class PromoDateRangeConstraint extends SymfonyConstraint {
  public string $message = 'La fecha de fin no puede ser anterior a la fecha de inicio.';
}

6.2 Validador de restricción

web/modules/custom/promo_kit/src/Plugin/Validation/Constraint/PromoDateRangeConstraintValidator.php

php
class PromoDateRangeConstraintValidator extends ConstraintValidator {
  public function validate(mixed $entity, Constraint $constraint): void {
    if (!isset($entity)) {
      return;
    }

    $date_field = 'promo_date_range';

    if (!$entity->hasField($date_field) || $entity->get($date_field)->isEmpty()) {
      return;
    }

    $start = $entity->get($date_field)->value;
    $end = $entity->get($date_field)->end_value;

    if ($end && $start && $end < $start) {
      $this->context->addViolation($constraint->message);
    }
  }
}

6.3 Adjuntar restricción a entidad

La restricción se adjunta a nivel de entidad (type: entity:promo_kit_promo), por lo que valida automáticamente durante la validación de entidad.

Para restricciones a nivel de campo, usarías hook_entity_bundle_field_info_alter() para adjuntar la restricción a un campo específico.


7. Hooks OO: Enfoque moderno de Drupal 11.2

Crear la clase de hook:

web/modules/custom/promo_kit/src/Hook/PromoHooks.php

php
21 lines
class PromoHooks {
  use StringTranslationTrait;

  #[Hook('entity_presave')]
  public function validateDates(EntityInterface $entity): void {
    if ($entity->getEntityTypeId() !== 'promo_kit_promo') {
      return;
    }

    $date_field = 'promo_date_range';

    if ($entity->hasField($date_field) && !$entity->get($date_field)->isEmpty()) {
      $start = $entity->get($date_field)->value;
      $end = $entity->get($date_field)->end_value;

      if ($end && $start && $end < $start) {
        throw new \\InvalidArgumentException('Error lógico: La fecha de fin de promo no puede ser anterior a la fecha de inicio.');
      }
    }
  }
}

Nota: Esta validación presave es un respaldo. La restricción proporciona mejor UX mostrando errores de formulario. El hook presave atrapa cualquier guardado programático que evite la validación de formulario.

Después de agregar clases de hook, limpiar caché:

bash
ddev drush cr

8. Frontend: Single Directory Components (SDC)

Crear la estructura del componente:

bash
mkdir -p web/modules/custom/promo_kit/components/promo_banner

8.1 Definición del componente

web/modules/custom/promo_kit/components/promo_banner/promo_banner.component.yml

yaml
name: Banner promocional
status: stable
props:
type: object
properties:
title:{type: string}
link_url:{type: string}
style_variant:{type: string,enum:['alert','party','info']}
image:{type: object}

8.2 Plantilla del componente

web/modules/custom/promo_kit/components/promo_banner/promo_banner.twig

plaintext
<div class="promo-banner promo-banner--{{ style_variant|default('info') }}">
  {% if image %}
    <div class="promo-banner__image">
      {{ image }}
    </div>
  {% endif %}

  <div class="promo-content">
    {% if title %}
      <strong class="promo-title">{{ title }}</strong>
    {% endif %}

    {% if link_url %}
      <a href="{{ link_url }}" class="promo-link">Échale un vistazo &rarr;</a>
    {% endif %}
  </div>
</div>

8.3 Estilos del componente

web/modules/custom/promo_kit/components/promo_banner/promo_banner.css

css
91 lines
.promo-banner {
  padding: 1rem;
  border-radius: 0.5rem;
  margin: 1rem 0;
  border-left: 4px solid;
  display: flex;
  gap: 1rem;
  align-items: center;
}

.promo-banner__image { flex-shrink: 0; }
.promo-banner__image img {
  max-width: 150px;
  height: auto;
  border-radius: 0.25rem;
  display: block;
}

.promo-banner .promo-content {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 1rem;
  flex: 1;
}

.promo-banner .promo-content strong {
  font-size: 1.2rem;
  font-weight: 600;
  flex: 1;
}

.promo-banner .promo-content a {
  color: inherit;
  text-decoration: none;
  font-weight: 500;
  padding: 0.5rem 1rem;
  border-radius: 0.25rem;
  transition: opacity 0.2s;
}

.promo-banner .promo-content a:hover {
  opacity: 0.8;
  text-decoration: underline;
}

/* Variante Info (por defecto) */
.promo-banner--info {
  background-color: #e7f3ff;
  border-left-color: #2196f3;
  color: #0d47a1;
}
.promo-banner--info .promo-content a {
  background-color: #2196f3;
  color: white;
}
.promo-banner--info .promo-content a:hover {
  background-color: #1976d2;
  text-decoration: none;
}

/* Variante Alert */
.promo-banner--alert {
  background-color: #fff3cd;
  border-left-color: #ff9800;
  color: #e65100;
}
.promo-banner--alert .promo-content a {
  background-color: #ff9800;
  color: white;
}
.promo-banner--alert .promo-content a:hover {
  background-color: #f57c00;
  text-decoration: none;
}

/* Variante Party */
.promo-banner--party {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  border-left-color: #764ba2;
  color: white;
}
.promo-banner--party .promo-content a {
  background-color: rgba(255, 255, 255, 0.2);
  color: white;
  border: 1px solid rgba(255, 255, 255, 0.3);
}
.promo-banner--party .promo-content a:hover {
  background-color: rgba(255, 255, 255, 0.3);
  text-decoration: none;
}

9. Conectar salida de entidad a SDC

9.1 Hook de tema

Registrar el hook de tema en promo_kit.module:

php
function promo_kit_theme(): array {
  return [
    'promo_kit_promo' => ['render element' => 'elements'],
  ];
}

function template_preprocess_promo_kit_promo(array &$variables): void {
  $variables['view_mode'] = $variables['elements']['#view_mode'];
  foreach (Element::children($variables['elements']) as $key) {
    $variables['content'][$key] = $variables['elements'][$key];
  }
}

9.2 Plantilla de entidad

web/modules/custom/promo_kit/templates/promo-kit-promo.html.twig

plaintext
31 lines
{#
/**
 * @file
 * Anulación de tema para una entidad de banner promocional.
 */
#}
{# Obtener la etiqueta de la entidad #}
{% set title_text = label|default('') %}

{# Obtener URL del enlace del campo si existe #}
{% set link_uri = null %}
{% if content.promo_link[0]['#url'] is defined %}
  {% set link_uri = content.promo_link[0]['#url'].toString() %}
{% endif %}

{# Obtener variante de estilo, por defecto 'info' #}
{% set style_variant = 'info' %}
{% if promo_kit_promo.promo_style.value is defined %}
  {% set style_variant = promo_kit_promo.promo_style.value %}
{% endif %}

{# Obtener marcado de imagen del contenido renderizado #}
{% set image_markup = content.promo_image|default(null) %}

{# Usar el componente SDC #}
{{ include('promo_kit:promo_banner', {
  title: title_text,
  link_url: link_uri,
  style_variant: style_variant,
  image: image_markup
}, with_context = false) }}

10. Permisos y rutas

10.1 Permisos

promo_kit.permissions.yml

yaml
administer promo_kit_promo:
title:'Administrar banners promocionales'
description:'Crear, editar, eliminar y gestionar banners promocionales.'
restrict access:true

view promo_kit_promo:
title:'Ver banners promocionales'
description:'Ver banners promocionales publicados.'

create promo_kit_promo:
title:'Crear banners promocionales'
description:'Crear nuevos banners promocionales.'

edit promo_kit_promo:
title:'Editar banners promocionales'
description:'Editar banners promocionales existentes.'

delete promo_kit_promo:
title:'Eliminar banners promocionales'
description:'Eliminar banners promocionales.'

10.2 Rutas

promo_kit.routing.yml

yaml
entity.promo_kit_promo.settings:
path:'admin/structure/promo-kit-promo'
defaults:
_form:'\\Drupal\\promo_kit\\Form\\PromoSettingsForm'
_title:'Banner promocional'
requirements:
_permission:'administer promo_kit_promo'

Las rutas de entidad (agregar, editar, eliminar, listar) son generadas automáticamente por el AdminHtmlRouteProvider especificado en la anotación de entidad.


11. Habilitar y usar

Habilitar el módulo:

bash
ddev drush en promo_kit -y
ddev drush cr

11.1 Crear promos

Ir a: /admin/content/promo/add

Valores de ejemplo:

  • Etiqueta: “Venta de verano 2025”
  • Descripción: “¡Obtén 50% de descuento en todos los productos!”
  • Rango de fechas: Inicio hoy, fin en 30 días
  • Enlace: https://example.com/sale
  • Estilo: party
  • Imagen del banner: Subir una imagen
  • Estado: Habilitado

11.2 Colocar el bloque

Ir a: /admin/structure/block

  • Hacer clic en “Colocar bloque”
  • Encontrar “Banners promocionales” (de la categoría Promo Kit)
  • Colocar en la región deseada (ej., Header, Content)
  • Configurar ajustes de visibilidad si es necesario
  • Guardar

El bloque automáticamente mostrará solo promos activos basado en la fecha/hora actual.


12. Lista de verificación de pruebas integral

12.1 Operaciones CRUD de promo

PruebaPasosEsperado
Crear promo/admin/content/promo/addGuardado exitosamente
Editar promoEditar promo existenteCambios guardados
Eliminar promoEliminar promoRemovido de la lista
RevisionesHabilitar revisiones, hacer cambiosAparece historial de revisiones
TraducciónAgregar idioma, traducirVersión traducida se renderiza

12.2 Lógica de horario

EscenarioInicioFinEsperado
Sin horariovacíovacíovisible
Inicio futuromañanavacíooculto hasta mañana
Inicio pasadoayervacíovisible
Fin futurovacíopróxima semanavisible
Fin pasadovacíoayeroculto
Rango activoayermañanavisible
Rango inválidopróxima semanaayererror de validación de formulario

12.3 Validación

PruebaPasosEsperado
Fin antes del inicioEstablecer fin antes del inicio, guardarError de formulario mostrado
Rango válidoEstablecer rango apropiado, guardarSe guarda exitosamente
Fechas vacíasDejar fechas vacías, guardarSe guarda exitosamente

12.4 Variantes de estilo

EstiloEsperado
infoTema azul
alertTema amarillo/naranja
partyGradiente púrpura
vacío/por defectoPor defecto a info

12.5 Visualización de bloque

PruebaPasosEsperado
Colocar bloqueDiseño de bloqueVisible en región
Sin promos activosTodos los promos expirados/deshabilitadosBloque se renderiza vacío
Múltiples promosCrear 3 promos activosLos 3 se muestran
Invalidación de cachéEditar promoBloque se actualiza inmediatamente
Caché basado en tiempoEsperar a que expire promoBloque se actualiza después de que expire el caché

13. Avanzado: Inmersión profunda en estrategia de caché

El módulo implementa una estrategia de caché sofisticada para contenido basado en tiempo:

13.1 Etiquetas de caché

php
public function getCacheTags(): array {
  return Cache::mergeTags(parent::getCacheTags(), ['promo_kit_promo_list']);
}

La etiqueta promo_kit_promo_list se invalida automáticamente cuando:

  • Se crea cualquier promo
  • Se actualiza cualquier promo
  • Se elimina cualquier promo

Esto asegura que el bloque se actualice inmediatamente cuando el contenido cambia.

13.2 Contextos de caché

php
public function getCacheContexts(): array {
  return Cache::mergeContexts(parent::getCacheContexts(), ['user.permissions']);
}

El contexto user.permissions asegura que diferentes usuarios vean contenido apropiado basado en sus permisos de acceso.

13.3 Cache Max-Age dinámico

php
public function getCacheMaxAge(): int {
  return $this->promoManager->getSecondsUntilNextBoundary();
}

En lugar de una duración de caché fija (ej., 1 hora), el bloque calcula cuándo la próxima promo iniciará o terminará, y establece que el caché expire en ese momento exacto.

Ejemplo:

  • Tiempo actual: 2:00 PM
  • Promo A termina: 2:30 PM
  • Promo B inicia: 3:00 PM
  • Cache max-age: 1800 segundos (30 minutos, hasta que termine Promo A)

Esto asegura que las promos aparezcan/desaparezcan en el momento correcto sin sobre-caché o sub-caché.


14. Solución de problemas

“No veo el estilo SDC”

  1. Confirmar que existen archivos de componente:
    • components/promo_banner/promo_banner.component.yml
    • components/promo_banner/promo_banner.twig
    • components/promo_banner/promo_banner.css
  2. Limpiar caché:
bash
ddev drush cr
  1. Verificar nombre de archivo de plantilla:
    • Debe ser: promo-kit-promo.html.twig
    • Ubicación: directorio templates/

“El bloque muestra promos expirados”

  1. Verificar rangos de fechas de promo en UI de admin
  2. Verificar que la zona horaria del servidor coincida con la zona horaria esperada
  3. Limpiar caché:
bash
ddev drush cr
  1. Verificar lógica de consulta PromoManager en getActivePromos()

“La validación no se activa”

  1. Confirmar que existen archivos de restricción y están correctamente espaciados de nombres
  2. Limpiar caché dos veces (el descubrimiento de plugins puede ser pegajoso):
bash
ddev drush cr
ddev drush cr
  1. Verificar tipo de entidad en anotación de restricción: type: ['entity:promo_kit_promo']

“Errores de permiso denegado”

  1. Otorgar permisos apropiados:
    • /admin/people/permissions
    • Buscar “promo”
    • Otorgar permisos a roles apropiados
  2. Limpiar caché:
bash
ddev drush cr

15. Lo que construimos (Resumen de arquitectura)

Entidad de contenido personalizada

  • ID de entidad: promo_kit_promo
  • Clase base: EditorialContentEntityBase
  • Características: Revisiones, traducciones, flujo de trabajo de publicación
  • Tablas: 4 tablas (base, field_data, revision, field_revision)

Capa de servicio

  • PromoManager: Lógica de negocio para consultar promos activos
  • Métodos: getActivePromos(), getSecondsUntilNextBoundary()
  • Beneficios: Reutilizable, testeable, filtrado basado en consultas

Plugin de bloque

  • ID: promo_banner_block
  • Características: Inyección de dependencias, caché apropiado
  • Caché: Etiquetas, contextos, max-age dinámico

Validación

  • Restricción: PromoDateRangeConstraint
  • Validador: PromoDateRangeConstraintValidator
  • UX: Errores de formulario en lugar de excepciones

Hooks OO

  • Clase: PromoHooks
  • Hook: entity_presave con atributo #[Hook]
  • Propósito: Validación de respaldo para guardados programáticos

Tematización SDC

  • Componente: promo_banner
  • Archivos: component.yml, twig, css
  • Integración: Plantilla de entidad incluye componente SDC

16. Cuándo usar entidades personalizadas vs. Block Content

Usar entidades personalizadas cuando:

  • Necesitas lógica personalizada de almacenamiento/consulta
  • Tienes reglas de negocio complejas
  • Necesitas rutas/UI personalizadas
  • Quieres control total sobre el modelo de datos
  • Estás construyendo un tipo de contenido específico del dominio

Usar Block Content cuando:

  • Necesitas bloques de contenido reutilizables simples
  • Las características principales de Block Content son suficientes
  • Quieres aprovechar Views para visualización
  • No necesitas lógica de consulta personalizada
  • Quieres mantener menos código

Promo Kit usa una entidad personalizada porque:

  • Necesitamos lógica de consulta personalizada para filtrado basado en fechas
  • Queremos una capa de servicio dedicada (PromoManager)
  • Tenemos requisitos específicos de caché
  • Queremos control total sobre la estructura de entidad

Conclusión

Construimos Promo Kit usando mejores prácticas modernas de Drupal 11.2:

  1. Entidad de contenido personalizada con prefijo apropiado (promo_kit_promo)
  2. Capa de servicio (PromoManager) para lógica de negocio
  3. Filtrado basado en consultas (escalable, no cargando todas las entidades)
  4. Plugin de bloque con inyección de dependencias apropiada
  5. Caché sofisticado (etiquetas, contextos, max-age dinámico)
  6. Restricciones de validación (UX apropiado)
  7. Hooks OO (enfoque moderno basado en atributos)
  8. Tematización SDC (frontend basado en componentes)

Esta arquitectura está lista para producción, es mantenible y sigue las convenciones de Drupal 11.2. El módulo demuestra cómo construir entidades personalizadas de la manera correcta—con separación apropiada de responsabilidades, código testeable y excelente rendimiento.


17. Componentes adicionales del módulo

17.1 Manejador de control de acceso

web/modules/custom/promo_kit/src/PromoAccessControlHandler.php

El manejador de control de acceso gestiona permisos para entidades promo:

  • Acceso de vista: Requiere permiso view promo_kit_promo (y la entidad debe estar publicada)
  • Acceso de creación: Requiere permiso create promo_kit_promo
  • Acceso de actualización: Requiere permiso edit promo_kit_promo
  • Acceso de eliminación: Requiere permiso delete promo_kit_promo
  • Acceso de admin: administer promo_kit_promo evita todas las verificaciones

17.2 Constructor de lista

web/modules/custom/promo_kit/src/PromoListBuilder.php

Proporciona la página de listado de admin en /admin/content/promo:

  • Muestra todos los promos en una tabla
  • Muestra etiqueta, estado, autor, fecha de creación
  • Proporciona enlaces de operaciones editar/eliminar
  • Soporta operaciones en lote

17.3 Clases de formulario

PromoForm (src/Form/PromoForm.php)

  • Formulario de agregar/editar para entidades promo
  • Maneja widgets de campo
  • Valida entrada
  • Guarda entidad

PromoSettingsForm (src/Form/PromoSettingsForm.php)

  • Formulario de configuración para tipo de entidad promo
  • Accesible en /admin/structure/promo-kit-promo
  • Permite gestión de campos vía Field UI

17.4 Constructor de vista

web/modules/custom/promo_kit/src/PromoViewBuilder.php

Renderiza entidades promo:

  • Construye arrays de renderizado para visualización
  • Aplica modos de vista
  • Se integra con sistema de temas
  • Maneja formateadores de campo

17.5 Hooks de integración de usuario

En promo_kit.module, manejamos operaciones de cuenta de usuario:

hook_user_cancel()

  • user_cancel_block_unpublish: Despublica promos del usuario
  • user_cancel_reassign: Reasigna promos al usuario anónimo

hook_user_predelete()

  • Elimina todos los promos propiedad del usuario
  • Elimina todas las revisiones de promo propiedad del usuario

Esto asegura integridad de datos cuando se eliminan cuentas de usuario.


18. Gestión de configuración

18.1 Exportar configuración

Si agregas campos vía Field UI (no campos base), expórtalos:

bash
ddev drush cex -y

18.2 Enviar configuración por defecto

Copiar archivos YAML relevantes de config/sync/ a config/install/:

bash
# Ejemplo: Si agregaste un campo vía UI
cp config/sync/field.storage.promo_kit_promo.field_custom.yml \\
   web/modules/custom/promo_kit/config/install/

cp config/sync/field.field.promo_kit_promo.promo_kit_promo.field_custom.yml \\
   web/modules/custom/promo_kit/config/install/

El módulo actualmente se envía con configuración mínima (solo configuraciones de acción para operaciones en lote).

18.3 Campos de configuración vs. campos base

Campos base (lo que usamos):

  • Definidos en código (baseFieldDefinitions())
  • Siempre presentes, no pueden eliminarse vía UI
  • Portables entre entornos
  • Controlados por versión en código

Campos de configuración (alternativa):

  • Definidos vía Field UI
  • Almacenados como configuración YAML
  • Pueden agregarse/removerse vía UI
  • Requieren gestión de configuración para portabilidad

Promo Kit usa campos base porque:

  • La estructura de campo es fija (no configurable por constructor de sitio)
  • Queremos presencia garantizada de campo
  • Queremos control de versión basado en código
  • No necesitamos personalización de campo por sitio

19. Estrategias de prueba

19.1 Lista de verificación de prueba manual

CRUD de entidad

  • Crear promo con todos los campos
  • Editar promo, cambiar valores
  • Eliminar promo
  • Ver lista de promo en /admin/content/promo

Lógica de fecha

  • Crear promo sin fechas (debería mostrarse siempre)
  • Crear promo con inicio futuro (debería ocultarse hasta el inicio)
  • Crear promo con fin pasado (debería ocultarse)
  • Crear promo con rango activo (debería mostrarse)

Validación

  • Intentar guardar promo con fin antes del inicio (debería mostrar error)
  • Guardar promo con fechas válidas (debería tener éxito)

Visualización de bloque

  • Colocar bloque en región
  • Verificar que solo se muestren promos activos
  • Deshabilitar un promo, verificar que desaparezca
  • Crear nuevo promo, verificar que aparezca

Caché

  • Editar promo, verificar que el bloque se actualice inmediatamente
  • Esperar a que expire promo, verificar que desaparezca (puede tomar hasta cache max-age)

Permisos

  • Probar como usuario anónimo (no debería ver páginas de admin)
  • Probar como usuario autenticado con permiso de vista (debería ver promos)
  • Probar como admin (debería ver todo)

19.2 Prueba automatizada (opcional)

Para módulos de producción, considera agregar:

Pruebas unitarias

  • Probar lógica PromoManager
  • Probar cálculos de fecha
  • Probar validador de restricción

Pruebas Kernel

  • Probar operaciones CRUD de entidad
  • Probar lógica de consulta
  • Probar control de acceso

Pruebas funcionales

  • Probar envío de formulario
  • Probar visualización de bloque
  • Probar permisos

Ejemplo de estructura de prueba:

plaintext
tests/
  src/
    Unit/
      PromoManagerTest.php
    Kernel/
      PromoEntityTest.php
    Functional/
      PromoBlockTest.php

20. Consideraciones de rendimiento

20.1 Optimización de consultas

PromoManager usa consultas de entidad con condiciones, no cargando todas las entidades:

Bueno (lo que hacemos):

php
$query->condition('status', 1)
      ->condition('promo_date_range.value', $now, '<=');
$ids = $query->execute();
$promos = $storage->loadMultiple($ids);

Malo (no hagas esto):

php
$all_promos = $storage->loadMultiple();
$active = array_filter($all_promos, function($promo) {
  // Filtrar en PHP
});

20.2 Estrategia de caché

El bloque implementa tres dimensiones de caché:

  1. Etiquetas de caché: Invalidar cuando cambia el contenido
  2. Contextos de caché: Variar por permisos de usuario
  3. Cache Max-Age: Expirar en el próximo límite

Esto asegura:

  • Contenido fresco cuando se editan promos
  • Contenido correcto para diferentes usuarios
  • Actualizaciones oportunas cuando expiran/activan promos

20.3 Índices de base de datos

La entidad automáticamente obtiene índices en:

  • id (clave primaria)
  • uuid (único)
  • revision_id
  • langcode
  • status

Para sitios de alto tráfico con muchos promos, considera agregar índices personalizados en:

  • promo_date_range.value (fecha de inicio)
  • promo_date_range.end_value (fecha de fin)

Esto puede hacerse en un hook_schema_alter() o hook de actualización.


21. Extender el módulo

21.1 Agregar un campo de peso

Para controlar orden de visualización:

  1. Agregar campo base en Promo::baseFieldDefinitions():
php
$fields['weight'] = BaseFieldDefinition::create('integer')
  ->setLabel(t('Peso'))
  ->setDescription(t('Pesos menores aparecen primero.'))
  ->setDefaultValue(0)
  ->setDisplayOptions('form', [
    'type' => 'number',
    'weight' => 20,
  ]);
  1. Actualizar consulta PromoManager:
php
$query->sort('weight', 'ASC');

21.2 Agregar categorías/etiquetas

Para categorizar promos:

  1. Agregar campo de referencia de entidad:
php
$fields['category'] = BaseFieldDefinition::create('entity_reference')
  ->setLabel(t('Categoría'))
  ->setSetting('target_type', 'taxonomy_term')
  ->setSetting('handler', 'default:taxonomy_term')
  ->setSetting('handler_settings', [
    'target_bundles' => ['promo_categories' => 'promo_categories'],
  ]);
  1. Crear vocabulario de taxonomía promo_categories
  2. Actualizar bloque para filtrar por categoría (agregar configuración de bloque)

21.3 Agregar modos de vista

Para soportar diferentes estilos de visualización:

  1. Crear modo de vista vía UI o configuración:
    • /admin/structure/display-modes/view/add/promo_kit_promo
  2. Configurar campos para el modo de vista:
    • /admin/structure/promo-kit-promo/display/[view_mode]
  3. Usar en bloque:
php
$build['#items'][] = $view_builder->view($promo, 'teaser');

21.4 Agregar soporte de API REST

Para exponer promos vía REST:

  1. Agregar dependencia en promo_kit.info.yml:
yaml
dependencies:
- rest:rest
  1. Habilitar recurso REST:
bash
ddev drush en rest -y
  1. Configurar recurso REST para tipo de entidad promo_kit_promo
  2. Acceder vía: /promo_kit_promo/{id}?_format=json

22. Lista de verificación de despliegue

22.1 Pre-despliegue

✅ Probar en entorno de staging ✅ Verificar que todos los promos se muestren correctamente ✅ Probar visibilidad basada en fechas ✅ Probar restricciones de validación ✅ Probar permisos para todos los roles ✅ Verificar comportamiento de caché ✅ Verificar rendimiento con volumen de datos realista

22.2 Pasos de despliegue

  1. Habilitar módulo:
bash
drush en promo_kit -y
  1. Limpiar caché:
bash
drush cr
  1. Importar configuración (si se usa gestión de configuración):
bash
drush cim -y
  1. Ejecutar hooks de actualización (si los hay):
bash
drush updb -y
  1. Verificar permisos:
bash
drush role:perm:list authenticated
  1. Colocar bloque (si no está en configuración):
    • Vía UI: /admin/structure/block
    • O vía Drush: drush block:place promo_banner_block

22.3 Post-despliegue

✅ Verificar que el bloque aparezca en frontend ✅ Crear promo de prueba y verificar que se muestre ✅ Verificar logs de error por cualquier problema ✅ Monitorear rendimiento ✅ Entrenar editores de contenido en crear promos


23. Trampas comunes y soluciones

Trampa 1: Olvidar accessCheck()

Error:

php
$query = $storage->getQuery();
$ids = $query->execute(); // Error fatal en D11

Solución:

php
$query = $storage->getQuery();
$query->accessCheck(TRUE); // o FALSE para consultas internas
$ids = $query->execute();

Trampa 2: Problemas de zona horaria

Problema: Las fechas no coinciden con el comportamiento esperado

Solución: Siempre usar UTC para comparaciones de fecha:

php
$now = new DrupalDateTime('now', 'UTC');

Trampa 3: El caché no se invalida

Problema: El bloque muestra datos obsoletos después de editar promo

Solución: Asegurar que las etiquetas de caché sean correctas:

php
public function getCacheTags(): array {
  return Cache::mergeTags(parent::getCacheTags(), ['promo_kit_promo_list']);
}

Trampa 4: La validación no funciona

Problema: La restricción no se activa

Solución: Limpiar caché dos veces (descubrimiento de plugins):

bash
drush cr && drush cr

Trampa 5: Plantilla no encontrada

Problema: Error “Template not found”

Solución: Verificar que el nombre del archivo coincida con el hook de tema:

  • Hook: promo_kit_promo
  • Plantilla: promo-kit-promo.html.twig (guiones bajos se convierten en guiones)

24. Recursos y lectura adicional

Documentación oficial de Drupal

Específico de Drupal 11

Herramientas de desarrollo


Apéndice A: Estructura completa de archivos

plaintext
45 lines
promo_kit/
├── components/
│   └── promo_banner/
│       ├── promo_banner.component.yml
│       ├── promo_banner.twig
│       └── promo_banner.css
├── config/
│   └── install/
│       ├── field.field.promo_kit_promo.promo_kit_promo.field_date_range.yml
│       ├── field.storage.promo_kit_promo.field_date_range.yml
│       ├── system.action.promo_kit_promo_delete_action.yml
│       └── system.action.promo_kit_promo_save_action.yml
├── src/
│   ├── Entity/
│   │   └── Promo.php
│   ├── Form/
│   │   ├── PromoForm.php
│   │   └── PromoSettingsForm.php
│   ├── Hook/
│   │   └── PromoHooks.php
│   ├── Plugin/
│   │   ├── Block/
│   │   │   └── PromoBannerBlock.php
│   │   └── Validation/
│   │       └── Constraint/
│   │           ├── PromoDateRangeConstraint.php
│   │           └── PromoDateRangeConstraintValidator.php
│   ├── PromoAccessControlHandler.php
│   ├── PromoInterface.php
│   ├── PromoListBuilder.php
│   ├── PromoManager.php
│   └── PromoViewBuilder.php
├── templates/
│   └── promo-kit-promo.html.twig
├── promo_kit.info.yml
├── promo_kit.install
├── promo_kit.links.action.yml
├── promo_kit.links.contextual.yml
├── promo_kit.links.menu.yml
├── promo_kit.links.task.yml
├── promo_kit.module
├── promo_kit.permissions.yml
├── promo_kit.routing.yml
├── promo_kit.services.yml
└── README.md

Apéndice B: Patrones clave de Drupal 11.2 utilizados

1. Atributos PHP 8 para definición de entidad

php
#[ContentEntityType(
  id: 'promo_kit_promo',
  label: new TranslatableMarkup('Banner promocional'),
  // ...
)]
class Promo extends EditorialContentEntityBase { }

2. Hooks OO con atributos

php
#[Hook('entity_presave')]
public function validateDates(EntityInterface $entity): void { }

3. Restricciones de validación

php
#[Constraint(
  id: 'PromoDateRange',
  label: new TranslatableMarkup('Rango de fechas de promo'),
)]
class PromoDateRangeConstraint extends SymfonyConstraint { }

4. Promoción de propiedades de constructor

php
public function __construct(
  private readonly EntityTypeManagerInterface $entityTypeManager,
) {}

5. Propiedades tipadas

php
public string $message = 'La fecha de fin no puede ser anterior a la fecha de inicio.';

6. Propiedades readonly

php
protected readonly PromoManager $promoManager;

7. Sugerencias de tipo Mixed

php
public function validate(mixed $entity, Constraint $constraint): void { }

Estos patrones son todas características de PHP 8.3+ que Drupal 11.2 abraza completamente.


Fin del tutorial

Ahora tienes un módulo completo de Drupal 11.2 listo para producción que demuestra prácticas de desarrollo modernas. El módulo Promo Kit muestra entidades personalizadas, capas de servicio, validación, caché, hooks OO y tematización SDC—todas las herramientas que necesitas para construir aplicaciones Drupal sofisticadas.