Drupal 11.2.x no es solo “otro lanzamiento puntual”. Es el momento donde las expectativas modernas de Drupal se endurecen:
.module.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)
Los mercadólogos necesitan banners promocionales que aparezcan en el sitio.
promo_kit_promo (no Block Content)Promo::baseFieldDefinitions()PromoBannerBlock muestra promos activosPromoDateRangeConstraint valida lógica de fechasPromoHooks para validación presave de entidadpromo_banner para tematizaciónDrupal 11 es estricto sobre PHP moderno. Si tu stack local es “suficientemente cercano”, no es suficiente.
# MacOS / Linux (Homebrew)
brew install ddev/ddev/ddevcomposer 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 -yGenerar el módulo:
ddev drush generate moduleUsar prompts como:
.module? Sí (lo usaremos para hooks de tema)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:linkCrear la clase de entidad:
web/modules/custom/promo_kit/src/Entity/Promo.php
Esta entidad extiende EditorialContentEntityBase que proporciona:
Características clave:
promo_kit_promo (con prefijo para evitar colisiones)promo_kit_promo, promo_kit_promo_field_data, promo_kit_promo_revision, promo_kit_promo_field_revisionLa 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 tiempoCrear el servicio:
web/modules/custom/promo_kit/src/PromoManager.php
Este servicio proporciona:
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:
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']Crear el plugin de bloque:
web/modules/custom/promo_kit/src/Plugin/Block/PromoBannerBlock.php
Este bloque:
PromoManager y EntityTypeManagerInterface$this->promoManager->getActivePromos()promo_kit_promo_list (invalida cuando cualquier promo cambia)user.permissions (verificación de acceso afecta resultados)#[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();
}
}En lugar de lanzar excepciones en presave (que causa pantallas blancas), usa una restricción de validación para mostrar errores de formulario apropiados.
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.';
}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);
}
}
}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.
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 crCrear la estructura del componente:
mkdir -p web/modules/custom/promo_kit/components/promo_bannerweb/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}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>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;
}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];
}
}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) }}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.'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.
Habilitar el módulo:
ddev drush en promo_kit -y
ddev drush crIr a: /admin/content/promo/add
Valores de ejemplo:
https://example.com/saleIr a: /admin/structure/block
El bloque automáticamente mostrará solo promos activos basado en la fecha/hora actual.
| 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 |
| 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 |
| 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 |
| Estilo | Esperado |
|---|---|
| info | Tema azul |
| alert | Tema amarillo/naranja |
| party | Gradiente púrpura |
| vacío/por defecto | Por defecto a info |
| 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é |
El módulo implementa una estrategia de caché sofisticada para contenido basado en tiempo:
public function getCacheTags(): array {
return Cache::mergeTags(parent::getCacheTags(), ['promo_kit_promo_list']);
}La etiqueta promo_kit_promo_list se invalida automáticamente cuando:
Esto asegura que el bloque se actualice inmediatamente cuando el contenido cambia.
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.
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:
Esto asegura que las promos aparezcan/desaparezcan en el momento correcto sin sobre-caché o sub-caché.
Echo Flow ayuda a empresas canadienses con ingeniería Drupal de nivel empresarial.
components/promo_banner/promo_banner.component.ymlcomponents/promo_banner/promo_banner.twigcomponents/promo_banner/promo_banner.cssddev drush crpromo-kit-promo.html.twigtemplates/ddev drush crgetActivePromos()ddev drush cr
ddev drush crtype: ['entity:promo_kit_promo']/admin/people/permissionsddev drush crpromo_kit_promoEditorialContentEntityBasegetActivePromos(), getSecondsUntilNextBoundary()promo_banner_blockPromoDateRangeConstraintPromoDateRangeConstraintValidatorPromoHooksentity_presave con atributo #[Hook]promo_bannerPromo Kit usa una entidad personalizada porque:
Construimos Promo Kit usando mejores prácticas modernas de Drupal 11.2:
promo_kit_promo)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.
web/modules/custom/promo_kit/src/PromoAccessControlHandler.php
El manejador de control de acceso gestiona permisos para entidades promo:
view promo_kit_promo (y la entidad debe estar publicada)create promo_kit_promoedit promo_kit_promodelete promo_kit_promoadminister promo_kit_promo evita todas las verificacionesweb/modules/custom/promo_kit/src/PromoListBuilder.php
Proporciona la página de listado de admin en /admin/content/promo:
PromoForm (src/Form/PromoForm.php)
PromoSettingsForm (src/Form/PromoSettingsForm.php)
/admin/structure/promo-kit-promoweb/modules/custom/promo_kit/src/PromoViewBuilder.php
Renderiza entidades promo:
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ónimohook_user_predelete()
Esto asegura integridad de datos cuando se eliminan cuentas de usuario.
Si agregas campos vía Field UI (no campos base), expórtalos:
ddev drush cex -yCopiar 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).
Campos base (lo que usamos):
baseFieldDefinitions())Campos de configuración (alternativa):
Promo Kit usa campos base porque:
✅ CRUD de entidad
/admin/content/promo✅ Lógica de fecha
✅ Validación
✅ Visualización de bloque
✅ Caché
✅ Permisos
Para módulos de producción, considera agregar:
Pruebas unitarias
Pruebas Kernel
Pruebas funcionales
Ejemplo de estructura de prueba:
tests/
src/
Unit/
PromoManagerTest.php
Kernel/
PromoEntityTest.php
Functional/
PromoBlockTest.phpPromoManager 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
});El bloque implementa tres dimensiones de caché:
Esto asegura:
La entidad automáticamente obtiene índices en:
id (clave primaria)uuid (único)revision_idlangcodestatusPara 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.
Para controlar orden de visualización:
Promo::baseFieldDefinitions():$fields['weight'] = BaseFieldDefinition::create('integer')
->setLabel(t('Peso'))
->setDescription(t('Pesos menores aparecen primero.'))
->setDefaultValue(0)
->setDisplayOptions('form', [
'type' => 'number',
'weight' => 20,
]);$query->sort('weight', 'ASC');Para categorizar promos:
$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'],
]);promo_categoriesPara soportar diferentes estilos de visualización:
/admin/structure/display-modes/view/add/promo_kit_promo/admin/structure/promo-kit-promo/display/[view_mode]$build['#items'][] = $view_builder->view($promo, 'teaser');Para exponer promos vía REST:
promo_kit.info.yml:dependencies:
- rest:restddev drush en rest -ypromo_kit_promo/promo_kit_promo/{id}?_format=json✅ 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
drush en promo_kit -ydrush crdrush cim -ydrush updb -ydrush role:perm:list authenticated/admin/structure/blockdrush block:place promo_banner_block✅ 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
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();❌ Problema: Las fechas no coinciden con el comportamiento esperado
✅ Solución: Siempre usar UTC para comparaciones de fecha:
$now = new DrupalDateTime('now', 'UTC');❌ 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']);
}❌ Problema: La restricción no se activa
✅ Solución: Limpiar caché dos veces (descubrimiento de plugins):
drush cr && drush cr❌ Problema: Error “Template not found”
✅ Solución: Verificar que el nombre del archivo coincida con el hook de tema:
promo_kit_promopromo-kit-promo.html.twig (guiones bajos se convierten en guiones)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#[ContentEntityType(
id: 'promo_kit_promo',
label: new TranslatableMarkup('Banner promocional'),
// ...
)]
class Promo extends EditorialContentEntityBase { }#[Hook('entity_presave')]
public function validateDates(EntityInterface $entity): void { }#[Constraint(
id: 'PromoDateRange',
label: new TranslatableMarkup('Rango de fechas de promo'),
)]
class PromoDateRangeConstraint extends SymfonyConstraint { }public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}public string $message = 'La fecha de fin no puede ser anterior a la fecha de inicio.';protected readonly PromoManager $promoManager;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.