Drupal 11.2.x isn’t just “another point release.” It’s the moment where modern Drupal expectations harden:
- PHP 8.3+ is the baseline.
- OO hooks with attributes replace traditional
.modulehook implementations. - Validation constraints provide better UX than throwing exceptions.
- SDC is a first-class theming workflow.
- Proper caching strategies are essential for time-based content.
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)
0. What We’re Building
Requirement
Marketers need Promo Banners that appear on the site.
- Fields: Label, Description, Banner Image, Link, Style (Alert/Info/Party), Schedule (Start/End date range)
- Logic: Promo is active if:
- status = published (enabled)
- start is empty or start ≤ now
- end is empty or end > now
- Frontend: Rendered by a dedicated Single Directory Component (SDC)
- Validation: End date cannot be before start date (validated via constraint)
Architecture
- Custom Content Entity:
promo_kit_promo(not Block Content) - Base Fields: Defined in
Promo::baseFieldDefinitions() - PromoManager Service: Handles active promo queries with proper date filtering
- Block Plugin:
PromoBannerBlockdisplays active promos - Validation Constraint:
PromoDateRangeConstraintvalidates date logic - OO Hooks:
PromoHooksclass for entity presave validation - SDC Component:
promo_bannerfor theming
1. Environment: Strict & Modern Drupal 11 (PHP 8.3+)
Drupal 11 is strict about modern PHP. If your local stack is “close enough,” it’s not enough.
Install DDEV (Homebrew)
# MacOS / Linux (Homebrew)
brew install ddev/ddev/ddevCreate the Drupal 11 project
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. Promo Kit Module: Scaffold
Generate the module:
ddev drush generate moduleUse prompts like:
- Module name: Promo Kit
- Machine name: promo_kit
- Description: Manages promotional banners with SDC integration.
- Dependencies: datetime_range, link, file
- Create
.modulefile? Yes (we’ll use it for theme hooks) - Create install file? Yes (optional)
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:link3. Custom Content Entity: Promo Banner
Create the entity class:
web/modules/custom/promo_kit/src/Entity/Promo.php
This entity extends EditorialContentEntityBase which provides:
- Revisions
- Publishing workflow (status field)
- Translation support
- Moderation integration
Key features:
- Entity ID:
promo_kit_promo(prefixed to avoid collisions) - Base Fields: label, status, description, date_range, link, image, style
- Tables:
promo_kit_promo,promo_kit_promo_field_data,promo_kit_promo_revision,promo_kit_promo_field_revision
The 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: Timestamps
4. PromoManager Service: Smart Query-Based Filtering
Create the service:
web/modules/custom/promo_kit/src/PromoManager.php
This service provides:
4.1 getActivePromos() Method
Returns 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:
- Uses UTC timezone (Drupal stores dates in UTC)
- Builds complex query conditions for date ranges
- Handles empty date fields (always active if no dates set)
- Scalable: filters at database level, not in PHP
4.2 getSecondsUntilNextBoundary() Method
Calculates 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']5. Block Plugin: Display Active Promos
Create the block plugin:
web/modules/custom/promo_kit/src/Plugin/Block/PromoBannerBlock.php
This block:
- Injects
PromoManagerandEntityTypeManagerInterface - Calls
$this->promoManager->getActivePromos() - Renders each promo using the view builder
- Implements proper caching:
- Cache tags:
promo_kit_promo_list(invalidates when any promo changes) - Cache contexts:
user.permissions(access checking affects results) - Cache max-age: Dynamic based on next boundary
#[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();
}
}6. Validation Constraint: Date Range Logic
Instead of throwing exceptions in presave (which causes white screens), use a validation constraint for proper form error display.
6.1 Constraint Plugin
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.';
}6.2 Constraint Validator
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 Attach Constraint to Entity
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.
7. OO Hooks: Modern Drupal 11.2 Approach
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 cr8. Frontend: Single Directory Components (SDC)
Create the component structure:
mkdir -p web/modules/custom/promo_kit/components/promo_banner8.1 Component Definition
web/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}8.2 Component Template
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>8.3 Component Styles
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;
}9. Connect Entity Output to SDC
9.1 Theme Hook
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];
}
}9.2 Entity Template
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) }}10. Permissions and Routes
10.1 Permissions
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.'10.2 Routes
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.
11. Enable and Use
Enable the module:
ddev drush en promo_kit -y
ddev drush cr11.1 Create Promos
Go to: /admin/content/promo/add
Example values:
- Label: “Summer Sale 2025”
- Description: “Get 50% off all products!”
- Date Range: Start today, end in 30 days
- Link:
https://example.com/sale - Style: party
- Banner Image: Upload an image
- Status: Enabled
11.2 Place the Block
Go to: /admin/structure/block
- Click “Place block”
- Find “Promo Banners” (from Promo Kit category)
- Place in desired region (e.g., Header, Content)
- Configure visibility settings if needed
- Save
The block will automatically display only active promos based on the current date/time.
12. Comprehensive Testing Checklist
12.1 Promo CRUD Operations
| 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 |
12.2 Schedule Logic
| 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 |
12.3 Validation
| 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 |
12.4 Style Variants
| Style | Expected |
|---|---|
| info | Blue theme |
| alert | Yellow/orange theme |
| party | Purple gradient |
| empty/default | Defaults to info |
12.5 Block Display
| 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 |
13. Advanced: Cache Strategy Deep Dive
The module implements a sophisticated caching strategy for time-based content:
13.1 Cache Tags
public function getCacheTags(): array {
return Cache::mergeTags(parent::getCacheTags(), ['promo_kit_promo_list']);
}The promo_kit_promo_list tag is automatically invalidated when:
- Any promo is created
- Any promo is updated
- Any promo is deleted
This ensures the block updates immediately when content changes.
13.2 Cache Contexts
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.
13.3 Dynamic Cache Max-Age
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:
- Current time: 2:00 PM
- Promo A ends: 2:30 PM
- Promo B starts: 3:00 PM
- Cache max-age: 1800 seconds (30 minutes, until Promo A ends)
This ensures promos appear/disappear at the correct time without over-caching or under-caching.
14. Troubleshooting
“I don’t see SDC styling”
- Confirm component files exist:
components/promo_banner/promo_banner.component.ymlcomponents/promo_banner/promo_banner.twigcomponents/promo_banner/promo_banner.css
- Clear cache:
ddev drush cr- Check template file name:
- Must be:
promo-kit-promo.html.twig - Location:
templates/directory
- Must be:
“Block shows expired promos”
- Check promo date ranges in admin UI
- Verify server timezone matches expected timezone
- Clear cache:
ddev drush cr- Check PromoManager query logic in
getActivePromos()
“Validation doesn’t trigger”
- Confirm constraint files exist and are properly namespaced
- Clear cache twice (plugin discovery can be sticky):
ddev drush cr
ddev drush cr- Check entity type in constraint annotation:
type: ['entity:promo_kit_promo']
“Permission denied errors”
- Grant appropriate permissions:
/admin/people/permissions- Search for “promo”
- Grant permissions to appropriate roles
- Clear cache:
ddev drush cr15. What We Built (Architecture Summary)
Custom Content Entity
- Entity ID:
promo_kit_promo - Base class:
EditorialContentEntityBase - Features: Revisions, translations, publishing workflow
- Tables: 4 tables (base, field_data, revision, field_revision)
Service Layer
- PromoManager: Business logic for querying active promos
- Methods:
getActivePromos(),getSecondsUntilNextBoundary() - Benefits: Reusable, testable, query-based filtering
Block Plugin
- ID:
promo_banner_block - Features: Dependency injection, proper caching
- Caching: Tags, contexts, dynamic max-age
Validation
- Constraint:
PromoDateRangeConstraint - Validator:
PromoDateRangeConstraintValidator - UX: Form errors instead of exceptions
OO Hooks
- Class:
PromoHooks - Hook:
entity_presavewith#[Hook]attribute - Purpose: Backup validation for programmatic saves
SDC Theming
- Component:
promo_banner - Files: component.yml, twig, css
- Integration: Entity template includes SDC component
16. When to Use Custom Entities vs. Block Content
Use Custom Entities When:
- You need custom storage/query logic
- You have complex business rules
- You need custom routes/UI
- You want full control over the data model
- You’re building a domain-specific content type
Use Block Content When:
- You need simple reusable content blocks
- Core Block Content features are sufficient
- You want to leverage Views for display
- You don’t need custom query logic
- You want less code to maintain
Promo Kit uses a custom entity because:
- We need custom query logic for date-based filtering
- We want a dedicated service layer (PromoManager)
- We have specific caching requirements
- We want full control over the entity structure
Conclusion
We built Promo Kit using modern Drupal 11.2 best practices:
- Custom Content Entity with proper prefixing (
promo_kit_promo) - Service Layer (PromoManager) for business logic
- Query-based filtering (scalable, not loading all entities)
- Block Plugin with proper dependency injection
- Sophisticated caching (tags, contexts, dynamic max-age)
- Validation Constraints (proper UX)
- OO Hooks (modern attribute-based approach)
- SDC Theming (component-based frontend)
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.
17. Additional Module Components
17.1 Access Control Handler
web/modules/custom/promo_kit/src/PromoAccessControlHandler.php
The access control handler manages permissions for promo entities:
- View access: Requires
view promo_kit_promopermission (and entity must be published) - Create access: Requires
create promo_kit_promopermission - Update access: Requires
edit promo_kit_promopermission - Delete access: Requires
delete promo_kit_promopermission - Admin access:
administer promo_kit_promobypasses all checks
17.2 List Builder
web/modules/custom/promo_kit/src/PromoListBuilder.php
Provides the admin listing page at /admin/content/promo:
- Displays all promos in a table
- Shows label, status, author, created date
- Provides edit/delete operation links
- Supports bulk operations
17.3 Form Classes
PromoForm (src/Form/PromoForm.php)
- Add/edit form for promo entities
- Handles field widgets
- Validates input
- Saves entity
PromoSettingsForm (src/Form/PromoSettingsForm.php)
- Configuration form for promo entity type
- Accessible at
/admin/structure/promo-kit-promo - Allows field management via Field UI
17.4 View Builder
web/modules/custom/promo_kit/src/PromoViewBuilder.php
Renders promo entities:
- Builds render arrays for display
- Applies view modes
- Integrates with theme system
- Handles field formatters
17.5 User Integration Hooks
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 user
hook_user_predelete()
- Deletes all promos owned by the user
- Deletes all promo revisions owned by the user
This ensures data integrity when user accounts are deleted.
18. Configuration Management
18.1 Exporting Configuration
If you add fields via Field UI (not base fields), export them:
ddev drush cex -y18.2 Shipping Default Configuration
Copy 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).
18.3 Config vs. Base Fields
Base Fields (what we use):
- Defined in code (
baseFieldDefinitions()) - Always present, can’t be deleted via UI
- Portable across environments
- Version controlled in code
Config Fields (alternative):
- Defined via Field UI
- Stored as YAML configuration
- Can be added/removed via UI
- Requires config management for portability
Promo Kit uses base fields because:
- The field structure is fixed (not site-builder configurable)
- We want guaranteed field presence
- We want code-based version control
- We don’t need per-site field customization
19. Testing Strategies
19.1 Manual Testing Checklist
✅ Entity CRUD
- Create promo with all fields
- Edit promo, change values
- Delete promo
- View promo list at
/admin/content/promo
✅ Date Logic
- Create promo with no dates (should always show)
- Create promo with future start (should hide until start)
- Create promo with past end (should hide)
- Create promo with active range (should show)
✅ Validation
- Try to save promo with end before start (should show error)
- Save promo with valid dates (should succeed)
✅ Block Display
- Place block in region
- Verify only active promos display
- Disable a promo, verify it disappears
- Create new promo, verify it appears
✅ Caching
- Edit promo, verify block updates immediately
- Wait for promo to expire, verify it disappears (may take up to cache max-age)
✅ Permissions
- Test as anonymous user (should not see admin pages)
- Test as authenticated user with view permission (should see promos)
- Test as admin (should see everything)
19.2 Automated Testing (Optional)
For production modules, consider adding:
Unit Tests
- Test PromoManager logic
- Test date calculations
- Test constraint validator
Kernel Tests
- Test entity CRUD operations
- Test query logic
- Test access control
Functional Tests
- Test form submission
- Test block display
- Test permissions
Example test structure:
tests/
src/
Unit/
PromoManagerTest.php
Kernel/
PromoEntityTest.php
Functional/
PromoBlockTest.php20. Performance Considerations
20.1 Query Optimization
The 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
});20.2 Caching Strategy
The block implements three caching dimensions:
- Cache Tags: Invalidate when content changes
- Cache Contexts: Vary by user permissions
- Cache Max-Age: Expire at next boundary
This ensures:
- Fresh content when promos are edited
- Correct content for different users
- Timely updates when promos expire/activate
20.3 Database Indexes
The entity automatically gets indexes on:
id(primary key)uuid(unique)revision_idlangcodestatus
For 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.
21. Extending the Module
21.1 Adding a Weight Field
To control display order:
- Add base field in
Promo::baseFieldDefinitions():
$fields['weight'] = BaseFieldDefinition::create('integer')
->setLabel(t('Weight'))
->setDescription(t('Lower weights appear first.'))
->setDefaultValue(0)
->setDisplayOptions('form', [
'type' => 'number',
'weight' => 20,
]);- Update PromoManager query:
$query->sort('weight', 'ASC');21.2 Adding Categories/Tags
To categorize promos:
- Add entity reference field:
$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'],
]);- Create taxonomy vocabulary
promo_categories - Update block to filter by category (add block configuration)
21.3 Adding View Modes
To support different display styles:
- Create view mode via UI or config:
/admin/structure/display-modes/view/add/promo_kit_promo
- Configure fields for the view mode:
/admin/structure/promo-kit-promo/display/[view_mode]
- Use in block:
$build['#items'][] = $view_builder->view($promo, 'teaser');21.4 Adding REST API Support
To expose promos via REST:
- Add dependency in
promo_kit.info.yml:
dependencies:
- rest:rest- Enable REST resource:
ddev drush en rest -y- Configure REST resource for
promo_kit_promoentity type - Access via:
/promo_kit_promo/{id}?_format=json
22. Deployment Checklist
22.1 Pre-Deployment
✅ 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
22.2 Deployment Steps
- Enable module:
drush en promo_kit -y- Clear cache:
drush cr- Import configuration (if using config management):
drush cim -y- Run update hooks (if any):
drush updb -y- Verify permissions:
drush role:perm:list authenticated- Place block (if not in config):
- Via UI:
/admin/structure/block - Or via Drush:
drush block:place promo_banner_block
- Via UI:
22.3 Post-Deployment
✅ 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
23. Common Pitfalls and Solutions
Pitfall 1: Forgetting 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();Pitfall 2: Timezone Issues
❌ Problem: Dates don’t match expected behavior
✅ Solution: Always use UTC for date comparisons:
$now = new DrupalDateTime('now', 'UTC');Pitfall 3: Cache Not Invalidating
❌ 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']);
}Pitfall 4: Validation Not Working
❌ Problem: Constraint doesn’t trigger
✅ Solution: Clear cache twice (plugin discovery):
drush cr && drush crPitfall 5: Template Not Found
❌ Problem: “Template not found” error
✅ Solution: Check file name matches theme hook:
- Hook:
promo_kit_promo - Template:
promo-kit-promo.html.twig(underscores become hyphens)
24. Resources and Further Reading
Official Drupal Documentation
Drupal 11 Specific
Development Tools
Appendix A: Complete File Structure
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.mdAppendix B: Key Drupal 11.2 Patterns Used
1. PHP 8 Attributes for Entity Definition
#[ContentEntityType(
id: 'promo_kit_promo',
label: new TranslatableMarkup('Promo Banner'),
// ...
)]
class Promo extends EditorialContentEntityBase { }2. OO Hooks with Attributes
#[Hook('entity_presave')]
public function validateDates(EntityInterface $entity): void { }3. Validation Constraints
#[Constraint(
id: 'PromoDateRange',
label: new TranslatableMarkup('Promo Date Range'),
)]
class PromoDateRangeConstraint extends SymfonyConstraint { }4. Constructor Property Promotion
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}5. Typed Properties
public string $message = 'The end date cannot be before the start date.';6. Readonly Properties
protected readonly PromoManager $promoManager;7. Mixed Type Hints
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.