Drupal 11.2.x isn’t just “another point release.” It’s the moment where modern Drupal expectations harden:
.module hook implementations.This tutorial builds Promo Kit (promo_kit)—a complete custom content entity module with modern Drupal 11.2 architecture:
✅ Custom Content Entity (promo_kit_promo) with full CRUD operations
✅ Base Fields (image/link/date range/style) defined in code
✅ PromoManager Service (query-based filtering for active promos)
✅ Block Plugin (displays active promos with smart caching)
✅ Validation Constraints (proper form validation UX)
✅ OO Hooks (modern attribute-based hook implementations)
✅ SDC Theming (Single Directory Components for frontend)
Marketers need Promo Banners that appear on the site.
promo_kit_promo (not Block Content)Promo::baseFieldDefinitions()PromoBannerBlock displays active promosPromoDateRangeConstraint validates date logicPromoHooks class for entity presave validationpromo_banner for themingDrupal 11 is strict about modern PHP. If your local stack is “close enough,” it’s not enough.
# 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 -yGenerate the module:
ddev drush generate moduleUse prompts like:
.module file? Yes (we’ll use it for theme hooks)Edit web/modules/custom/promo_kit/promo_kit.info.yml:
name:'Promo Kit'
type: module
description:'Manages promotional banners with SDC integration.'
package: Custom
core_version_requirement: ^10 || ^11
dependencies:
- datetime_range:datetime_range
- file:file
- link:linkCreate the entity class:
web/modules/custom/promo_kit/src/Entity/Promo.php
This entity extends EditorialContentEntityBase which provides:
Key features:
promo_kit_promo (prefixed to avoid collisions)promo_kit_promo, promo_kit_promo_field_data, promo_kit_promo_revision, promo_kit_promo_field_revisionThe entity uses PHP 8 attributes for configuration:
#[ContentEntityType(
id: 'promo_kit_promo',
label: new TranslatableMarkup('Promo Banner'),
// ... handlers, links, etc.
)]Base Fields include:
label: Title/name of the promostatus: Published/unpublisheddescription: Long text descriptionpromo_date_range: Daterange field (start/end)promo_link: Link fieldpromo_image: Image fieldpromo_style: List field (info/alert/party)uid: Owner referencecreated/changed: TimestampsCreate the service:
web/modules/custom/promo_kit/src/PromoManager.php
This service provides:
getActivePromos() MethodReturns active promos by pushing date filtering into the entity query (not loading all and filtering in 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);
// Date filtering logic with OR/AND groups
// ...
$ids = $query->execute();
return $storage->loadMultiple($ids);
}Key points:
getSecondsUntilNextBoundary() MethodCalculates dynamic cache max-age based on when the next promo starts or ends:
public function getSecondsUntilNextBoundary(): int {
// Find nearest future start/end date
// Return seconds until that boundary
// Default: 3600 (1 hour)
}This ensures the block cache expires at the right time when promo visibility changes.
Register the service in promo_kit.services.yml:
services:
promo_kit.manager:
class: Drupal\\promo_kit\\PromoManager
arguments:['@entity_type.manager']Create the block plugin:
web/modules/custom/promo_kit/src/Plugin/Block/PromoBannerBlock.php
This block:
PromoManager and EntityTypeManagerInterface$this->promoManager->getActivePromos()promo_kit_promo_list (invalidates when any promo changes)user.permissions (access checking affects results)#[Block(
id: "promo_banner_block",
admin_label: new TranslatableMarkup("Promo Banners"),
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();
}
}Instead of throwing exceptions in presave (which causes white screens), use a validation constraint for proper form error display.
web/modules/custom/promo_kit/src/Plugin/Validation/Constraint/PromoDateRangeConstraint.php
#[Constraint(
id: 'PromoDateRange',
label: new TranslatableMarkup('Promo Date Range', [], ['context' => 'Validation']),
type: ['entity:promo_kit_promo']
)]
class PromoDateRangeConstraint extends SymfonyConstraint {
public string $message = 'The end date cannot be before the start date.';
}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);
}
}
}The constraint is attached at the entity level (type: entity:promo_kit_promo), so it validates automatically during entity validation.
For field-level constraints, you would use hook_entity_bundle_field_info_alter() to attach the constraint to a specific field.
Create the hook class:
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('Logic Error: Promo End Date cannot be before Start Date.');
}
}
}
}Note: This presave validation is a backup. The constraint provides better UX by showing form errors. The presave hook catches any programmatic saves that bypass form validation.
After adding hook classes, clear cache:
ddev drush crCreate the component structure:
mkdir -p web/modules/custom/promo_kit/components/promo_bannerweb/modules/custom/promo_kit/components/promo_banner/promo_banner.component.yml
name: Promo Banner
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">Check it out →</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;
}
/* Info variant (default) */
.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;
}
/* Alert variant */
.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;
}
/* Party variant */
.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;
}Register the theme hook in 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
* Theme override for a promo banner entity.
*/
#}
{# Get the entity label #}
{% set title_text = label|default('') %}
{# Get link URL from field if it exists #}
{% set link_uri = null %}
{% if content.promo_link[0]['#url'] is defined %}
{% set link_uri = content.promo_link[0]['#url'].toString() %}
{% endif %}
{# Get style variant, default to 'info' #}
{% set style_variant = 'info' %}
{% if promo_kit_promo.promo_style.value is defined %}
{% set style_variant = promo_kit_promo.promo_style.value %}
{% endif %}
{# Get image markup from rendered content #}
{% set image_markup = content.promo_image|default(null) %}
{# Use the SDC component #}
{{ 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:'Administer promo banners'
description:'Create, edit, delete, and manage promo banners.'
restrict access:true
view promo_kit_promo:
title:'View promo banners'
description:'View published promo banners.'
create promo_kit_promo:
title:'Create promo banners'
description:'Create new promo banners.'
edit promo_kit_promo:
title:'Edit promo banners'
description:'Edit existing promo banners.'
delete promo_kit_promo:
title:'Delete promo banners'
description:'Delete promo banners.'promo_kit.routing.yml
entity.promo_kit_promo.settings:
path:'admin/structure/promo-kit-promo'
defaults:
_form:'\\Drupal\\promo_kit\\Form\\PromoSettingsForm'
_title:'Promo Banner'
requirements:
_permission:'administer promo_kit_promo'The entity routes (add, edit, delete, list) are automatically generated by the AdminHtmlRouteProvider specified in the entity annotation.
Enable the module:
ddev drush en promo_kit -y
ddev drush crGo to: /admin/content/promo/add
Example values:
https://example.com/saleGo to: /admin/structure/block
The block will automatically display only active promos based on the current date/time.
| Test | Steps | Expected |
|---|---|---|
| Create promo | /admin/content/promo/add | Saved successfully |
| Edit promo | Edit existing promo | Changes saved |
| Delete promo | Delete promo | Removed from list |
| Revisions | Enable revisions, make changes | Revision history appears |
| Translation | Add language, translate | Translated version renders |
| Scenario | Start | End | Expected |
|---|---|---|---|
| No schedule | empty | empty | visible |
| Future start | tomorrow | empty | hidden until tomorrow |
| Past start | yesterday | empty | visible |
| Future end | empty | next week | visible |
| Past end | empty | yesterday | hidden |
| Active range | yesterday | tomorrow | visible |
| Invalid range | next week | yesterday | form validation error |
| Test | Steps | Expected |
|---|---|---|
| End before start | Set end before start, save | Form error displayed |
| Valid range | Set proper range, save | Saves successfully |
| Empty dates | Leave dates empty, save | Saves successfully |
| Style | Expected |
|---|---|
| info | Blue theme |
| alert | Yellow/orange theme |
| party | Purple gradient |
| empty/default | Defaults to info |
| Test | Steps | Expected |
|---|---|---|
| Place block | Block layout | Visible in region |
| No active promos | All promos expired/disabled | Block renders empty |
| Multiple promos | Create 3 active promos | All 3 display |
| Cache invalidation | Edit promo | Block updates immediately |
| Time-based cache | Wait for promo to expire | Block updates after cache expires |
The module implements a sophisticated caching strategy for time-based content:
public function getCacheTags(): array {
return Cache::mergeTags(parent::getCacheTags(), ['promo_kit_promo_list']);
}The promo_kit_promo_list tag is automatically invalidated when:
This ensures the block updates immediately when content changes.
public function getCacheContexts(): array {
return Cache::mergeContexts(parent::getCacheContexts(), ['user.permissions']);
}The user.permissions context ensures different users see appropriate content based on their access permissions.
public function getCacheMaxAge(): int {
return $this->promoManager->getSecondsUntilNextBoundary();
}Instead of a fixed cache duration (e.g., 1 hour), the block calculates when the next promo will start or end, and sets the cache to expire at that exact moment.
Example:
This ensures promos appear/disappear at the correct time without over-caching or under-caching.
Echo Flow provides Canadian businesses with enterprise-grade Drupal engineering.
components/promo_banner/promo_banner.component.ymlcomponents/promo_banner/promo_banner.twigcomponents/promo_banner/promo_banner.cssddev drush crpromo-kit-promo.html.twigtemplates/ directoryddev drush crgetActivePromos()ddev drush cr
ddev drush crtype: ['entity:promo_kit_promo']/admin/people/permissionsddev drush crpromo_kit_promoEditorialContentEntityBasegetActivePromos(), getSecondsUntilNextBoundary()promo_banner_blockPromoDateRangeConstraintPromoDateRangeConstraintValidatorPromoHooksentity_presave with #[Hook] attributepromo_bannerPromo Kit uses a custom entity because:
We built Promo Kit using modern Drupal 11.2 best practices:
promo_kit_promo)This architecture is production-ready, maintainable, and follows Drupal 11.2 conventions. The module demonstrates how to build custom entities the right way—with proper separation of concerns, testable code, and excellent performance.
web/modules/custom/promo_kit/src/PromoAccessControlHandler.php
The access control handler manages permissions for promo entities:
view promo_kit_promo permission (and entity must be published)create promo_kit_promo permissionedit promo_kit_promo permissiondelete promo_kit_promo permissionadminister promo_kit_promo bypasses all checksweb/modules/custom/promo_kit/src/PromoListBuilder.php
Provides the admin listing page at /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
Renders promo entities:
In promo_kit.module, we handle user account operations:
hook_user_cancel()
user_cancel_block_unpublish: Unpublishes user’s promosuser_cancel_reassign: Reassigns promos to anonymous userhook_user_predelete()
This ensures data integrity when user accounts are deleted.
If you add fields via Field UI (not base fields), export them:
ddev drush cex -yCopy relevant YAML files from config/sync/ to config/install/:
# Example: If you added a field via 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/The module currently ships with minimal config (just action configs for bulk operations).
Base Fields (what we use):
baseFieldDefinitions())Config Fields (alternative):
Promo Kit uses base fields because:
✅ Entity CRUD
/admin/content/promo✅ Date Logic
✅ Validation
✅ Block Display
✅ Caching
✅ Permissions
For production modules, consider adding:
Unit Tests
Kernel Tests
Functional Tests
Example test structure:
tests/
src/
Unit/
PromoManagerTest.php
Kernel/
PromoEntityTest.php
Functional/
PromoBlockTest.phpThe PromoManager uses entity queries with conditions, not loading all entities:
✅ Good (what we do):
$query->condition('status', 1)
->condition('promo_date_range.value', $now, '<=');
$ids = $query->execute();
$promos = $storage->loadMultiple($ids);❌ Bad (don’t do this):
$all_promos = $storage->loadMultiple();
$active = array_filter($all_promos, function($promo) {
// Filter in PHP
});The block implements three caching dimensions:
This ensures:
The entity automatically gets indexes on:
id (primary key)uuid (unique)revision_idlangcodestatusFor high-traffic sites with many promos, consider adding custom indexes on:
promo_date_range.value (start date)promo_date_range.end_value (end date)This can be done in a hook_schema_alter() or update hook.
To control display order:
Promo::baseFieldDefinitions():$fields['weight'] = BaseFieldDefinition::create('integer')
->setLabel(t('Weight'))
->setDescription(t('Lower weights appear first.'))
->setDefaultValue(0)
->setDisplayOptions('form', [
'type' => 'number',
'weight' => 20,
]);$query->sort('weight', 'ASC');To categorize promos:
$fields['category'] = BaseFieldDefinition::create('entity_reference')
->setLabel(t('Category'))
->setSetting('target_type', 'taxonomy_term')
->setSetting('handler', 'default:taxonomy_term')
->setSetting('handler_settings', [
'target_bundles' => ['promo_categories' => 'promo_categories'],
]);promo_categoriesTo support different display styles:
/admin/structure/display-modes/view/add/promo_kit_promo/admin/structure/promo-kit-promo/display/[view_mode]$build['#items'][] = $view_builder->view($promo, 'teaser');To expose promos via REST:
promo_kit.info.yml:dependencies:
- rest:restddev drush en rest -ypromo_kit_promo entity type/promo_kit_promo/{id}?_format=json✅ Test on staging environment ✅ Verify all promos display correctly ✅ Test date-based visibility ✅ Test validation constraints ✅ Test permissions for all roles ✅ Verify caching behavior ✅ Check performance with realistic data volume
drush en promo_kit -ydrush crdrush cim -ydrush updb -ydrush role:perm:list authenticated/admin/structure/blockdrush block:place promo_banner_block✅ Verify block appears on frontend ✅ Create test promo and verify it displays ✅ Check error logs for any issues ✅ Monitor performance ✅ Train content editors on creating promos
accessCheck()❌ Error:
$query = $storage->getQuery();
$ids = $query->execute(); // Fatal error in D11✅ Solution:
$query = $storage->getQuery();
$query->accessCheck(TRUE); // or FALSE for internal queries
$ids = $query->execute();❌ Problem: Dates don’t match expected behavior
✅ Solution: Always use UTC for date comparisons:
$now = new DrupalDateTime('now', 'UTC');❌ Problem: Block shows stale data after editing promo
✅ Solution: Ensure cache tags are correct:
public function getCacheTags(): array {
return Cache::mergeTags(parent::getCacheTags(), ['promo_kit_promo_list']);
}❌ Problem: Constraint doesn’t trigger
✅ Solution: Clear cache twice (plugin discovery):
drush cr && drush cr❌ Problem: “Template not found” error
✅ Solution: Check file name matches theme hook:
promo_kit_promopromo-kit-promo.html.twig (underscores become hyphens)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('Promo Banner'),
// ...
)]
class Promo extends EditorialContentEntityBase { }#[Hook('entity_presave')]
public function validateDates(EntityInterface $entity): void { }#[Constraint(
id: 'PromoDateRange',
label: new TranslatableMarkup('Promo Date Range'),
)]
class PromoDateRangeConstraint extends SymfonyConstraint { }public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}public string $message = 'The end date cannot be before the start date.';protected readonly PromoManager $promoManager;public function validate(mixed $entity, Constraint $constraint): void { }These patterns are all PHP 8.3+ features that Drupal 11.2 embraces fully.
End of Tutorial
You now have a complete, production-ready Drupal 11.2 module that demonstrates modern development practices. The Promo Kit module showcases custom entities, service layers, validation, caching, OO hooks, and SDC theming—all the tools you need to build sophisticated Drupal applications.