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:
PromoBannerBlockmuestra promos activos - Restricción de validación:
PromoDateRangeConstraintvalida lógica de fechas - Hooks OO: Clase
PromoHookspara validación presave de entidad - Componente SDC:
promo_bannerpara 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)
# MacOS / Linux (Homebrew)
brew install ddev/ddev/ddevCrear el proyecto Drupal 11
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 -y2. Módulo Promo Kit: Andamiaje
Generar el módulo:
ddev drush generate moduleUsar 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? Sí (lo usaremos para hooks de tema) - ¿Crear archivo install? Sí (opcional)
Editar web/modules/custom/promo_kit/promo_kit.info.yml:
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:link3. 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:
#[ContentEntityType(
id: 'promo_kit_promo',
label: new TranslatableMarkup('Banner promocional'),
// ... manejadores, enlaces, etc.
)]Campos base incluyen:
label: Título/nombre de la promostatus: Publicado/no publicadodescription: Descripción de texto largopromo_date_range: Campo de rango de fechas (inicio/fin)promo_link: Campo de enlacepromo_image: Campo de imagenpromo_style: Campo de lista (info/alert/party)uid: Referencia al propietariocreated/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):
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:
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:
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
PromoManageryEntityTypeManagerInterface - 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
#[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
#[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
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
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é:
ddev drush cr8. Frontend: Single Directory Components (SDC)
Crear la estructura del componente:
mkdir -p web/modules/custom/promo_kit/components/promo_banner8.1 Definición del componente
web/modules/custom/promo_kit/components/promo_banner/promo_banner.component.yml
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
<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 →</a>
{% endif %}
</div>
</div>8.3 Estilos del componente
web/modules/custom/promo_kit/components/promo_banner/promo_banner.css
.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:
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
{#
/**
* @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
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
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:
ddev drush en promo_kit -y
ddev drush cr11.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
| Prueba | Pasos | Esperado |
|---|---|---|
| Crear promo | /admin/content/promo/add | Guardado exitosamente |
| Editar promo | Editar promo existente | Cambios guardados |
| Eliminar promo | Eliminar promo | Removido de la lista |
| Revisiones | Habilitar revisiones, hacer cambios | Aparece historial de revisiones |
| Traducción | Agregar idioma, traducir | Versión traducida se renderiza |
12.2 Lógica de horario
| Escenario | Inicio | Fin | Esperado |
|---|---|---|---|
| Sin horario | vacío | vacío | visible |
| Inicio futuro | mañana | vacío | oculto hasta mañana |
| Inicio pasado | ayer | vacío | visible |
| Fin futuro | vacío | próxima semana | visible |
| Fin pasado | vacío | ayer | oculto |
| Rango activo | ayer | mañana | visible |
| Rango inválido | próxima semana | ayer | error de validación de formulario |
12.3 Validación
| Prueba | Pasos | Esperado |
|---|---|---|
| Fin antes del inicio | Establecer fin antes del inicio, guardar | Error de formulario mostrado |
| Rango válido | Establecer rango apropiado, guardar | Se guarda exitosamente |
| Fechas vacías | Dejar fechas vacías, guardar | Se guarda exitosamente |
12.4 Variantes de estilo
| Estilo | Esperado |
|---|---|
| info | Tema azul |
| alert | Tema amarillo/naranja |
| party | Gradiente púrpura |
| vacío/por defecto | Por defecto a info |
12.5 Visualización de bloque
| Prueba | Pasos | Esperado |
|---|---|---|
| Colocar bloque | Diseño de bloque | Visible en región |
| Sin promos activos | Todos los promos expirados/deshabilitados | Bloque se renderiza vacío |
| Múltiples promos | Crear 3 promos activos | Los 3 se muestran |
| Invalidación de caché | Editar promo | Bloque se actualiza inmediatamente |
| Caché basado en tiempo | Esperar a que expire promo | Bloque 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é
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é
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
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”
- Confirmar que existen archivos de componente:
components/promo_banner/promo_banner.component.ymlcomponents/promo_banner/promo_banner.twigcomponents/promo_banner/promo_banner.css
- Limpiar caché:
ddev drush cr- Verificar nombre de archivo de plantilla:
- Debe ser:
promo-kit-promo.html.twig - Ubicación: directorio
templates/
- Debe ser:
“El bloque muestra promos expirados”
- Verificar rangos de fechas de promo en UI de admin
- Verificar que la zona horaria del servidor coincida con la zona horaria esperada
- Limpiar caché:
ddev drush cr- Verificar lógica de consulta PromoManager en
getActivePromos()
“La validación no se activa”
- Confirmar que existen archivos de restricción y están correctamente espaciados de nombres
- Limpiar caché dos veces (el descubrimiento de plugins puede ser pegajoso):
ddev drush cr
ddev drush cr- Verificar tipo de entidad en anotación de restricción:
type: ['entity:promo_kit_promo']
“Errores de permiso denegado”
- Otorgar permisos apropiados:
/admin/people/permissions- Buscar “promo”
- Otorgar permisos a roles apropiados
- Limpiar caché:
ddev drush cr15. 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_presavecon 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:
- Entidad de contenido personalizada con prefijo apropiado (
promo_kit_promo) - Capa de servicio (PromoManager) para lógica de negocio
- Filtrado basado en consultas (escalable, no cargando todas las entidades)
- Plugin de bloque con inyección de dependencias apropiada
- Caché sofisticado (etiquetas, contextos, max-age dinámico)
- Restricciones de validación (UX apropiado)
- Hooks OO (enfoque moderno basado en atributos)
- 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_promoevita 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 usuariouser_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:
ddev drush cex -y18.2 Enviar configuración por defecto
Copiar archivos YAML relevantes de config/sync/ a config/install/:
# 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:
tests/
src/
Unit/
PromoManagerTest.php
Kernel/
PromoEntityTest.php
Functional/
PromoBlockTest.php20. 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):
$query->condition('status', 1)
->condition('promo_date_range.value', $now, '<=');
$ids = $query->execute();
$promos = $storage->loadMultiple($ids);❌ Malo (no hagas esto):
$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é:
- Etiquetas de caché: Invalidar cuando cambia el contenido
- Contextos de caché: Variar por permisos de usuario
- 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_idlangcodestatus
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:
- Agregar campo base en
Promo::baseFieldDefinitions():
$fields['weight'] = BaseFieldDefinition::create('integer')
->setLabel(t('Peso'))
->setDescription(t('Pesos menores aparecen primero.'))
->setDefaultValue(0)
->setDisplayOptions('form', [
'type' => 'number',
'weight' => 20,
]);- Actualizar consulta PromoManager:
$query->sort('weight', 'ASC');21.2 Agregar categorías/etiquetas
Para categorizar promos:
- Agregar campo de referencia de entidad:
$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'],
]);- Crear vocabulario de taxonomía
promo_categories - 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:
- Crear modo de vista vía UI o configuración:
/admin/structure/display-modes/view/add/promo_kit_promo
- Configurar campos para el modo de vista:
/admin/structure/promo-kit-promo/display/[view_mode]
- Usar en bloque:
$build['#items'][] = $view_builder->view($promo, 'teaser');21.4 Agregar soporte de API REST
Para exponer promos vía REST:
- Agregar dependencia en
promo_kit.info.yml:
dependencies:
- rest:rest- Habilitar recurso REST:
ddev drush en rest -y- Configurar recurso REST para tipo de entidad
promo_kit_promo - 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
- Habilitar módulo:
drush en promo_kit -y- Limpiar caché:
drush cr- Importar configuración (si se usa gestión de configuración):
drush cim -y- Ejecutar hooks de actualización (si los hay):
drush updb -y- Verificar permisos:
drush role:perm:list authenticated- Colocar bloque (si no está en configuración):
- Vía UI:
/admin/structure/block - O vía Drush:
drush block:place promo_banner_block
- Vía UI:
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:
$query = $storage->getQuery();
$ids = $query->execute(); // Error fatal en D11✅ Solución:
$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:
$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:
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):
drush cr && drush crTrampa 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
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.mdApéndice B: Patrones clave de Drupal 11.2 utilizados
1. Atributos PHP 8 para definición de entidad
#[ContentEntityType(
id: 'promo_kit_promo',
label: new TranslatableMarkup('Banner promocional'),
// ...
)]
class Promo extends EditorialContentEntityBase { }2. Hooks OO con atributos
#[Hook('entity_presave')]
public function validateDates(EntityInterface $entity): void { }3. Restricciones de validación
#[Constraint(
id: 'PromoDateRange',
label: new TranslatableMarkup('Rango de fechas de promo'),
)]
class PromoDateRangeConstraint extends SymfonyConstraint { }4. Promoción de propiedades de constructor
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}5. Propiedades tipadas
public string $message = 'La fecha de fin no puede ser anterior a la fecha de inicio.';6. Propiedades readonly
protected readonly PromoManager $promoManager;7. Sugerencias de tipo Mixed
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.