Skip to main content
Login

Building the Future: A Practical Guide to Drupal 11.2 Custom Entity Development

Andrii Kocherhin profile picture
by Andrii Kocherhin
Cyber Drupal 11

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 .module hook 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: PromoBannerBlock displays active promos
  • Validation Constraint: PromoDateRangeConstraint validates date logic
  • OO Hooks: PromoHooks class for entity presave validation
  • SDC Component: promo_banner for 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)

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

Create the Drupal 11 project

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

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

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

2. Promo Kit Module: Scaffold

Generate the module:

bash
ddev drush generate module

Use prompts like:

  • Module name: Promo Kit
  • Machine name: promo_kit
  • Description: Manages promotional banners with SDC integration.
  • Dependencies: datetime_range, link, file
  • Create .module file? Yes (we’ll use it for theme hooks)
  • Create install file? Yes (optional)

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

yaml
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:link

3. 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:

php
#[ContentEntityType(
  id: 'promo_kit_promo',
  label: new TranslatableMarkup('Promo Banner'),
  // ... handlers, links, etc.
)]

Base Fields include:

  • label: Title/name of the promo
  • status: Published/unpublished
  • description: Long text description
  • promo_date_range: Daterange field (start/end)
  • promo_link: Link field
  • promo_image: Image field
  • promo_style: List field (info/alert/party)
  • uid: Owner reference
  • created/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):

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:

php
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:

yaml
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 PromoManager and EntityTypeManagerInterface
  • 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
php
32 lines
#[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

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

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

php
21 lines
class PromoHooks {
  use StringTranslationTrait;

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

    $date_field = 'promo_date_range';

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

      if ($end && $start && $end < $start) {
        throw new \\InvalidArgumentException('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:

bash
ddev drush cr

8. Frontend: Single Directory Components (SDC)

Create the component structure:

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

8.1 Component Definition

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

yaml
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

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

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

    {% if link_url %}
      <a href="{{ link_url }}" class="promo-link">Check it out &rarr;</a>
    {% endif %}
  </div>
</div>

8.3 Component Styles

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

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

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

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

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

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

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

/* 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:

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

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

9.2 Entity Template

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

plaintext
31 lines
{#
/**
 * @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

yaml
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

yaml
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:

bash
ddev drush en promo_kit -y
ddev drush cr

11.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

TestStepsExpected
Create promo/admin/content/promo/addSaved successfully
Edit promoEdit existing promoChanges saved
Delete promoDelete promoRemoved from list
RevisionsEnable revisions, make changesRevision history appears
TranslationAdd language, translateTranslated version renders

12.2 Schedule Logic

ScenarioStartEndExpected
No scheduleemptyemptyvisible
Future starttomorrowemptyhidden until tomorrow
Past startyesterdayemptyvisible
Future endemptynext weekvisible
Past endemptyyesterdayhidden
Active rangeyesterdaytomorrowvisible
Invalid rangenext weekyesterdayform validation error

12.3 Validation

TestStepsExpected
End before startSet end before start, saveForm error displayed
Valid rangeSet proper range, saveSaves successfully
Empty datesLeave dates empty, saveSaves successfully

12.4 Style Variants

StyleExpected
infoBlue theme
alertYellow/orange theme
partyPurple gradient
empty/defaultDefaults to info

12.5 Block Display

TestStepsExpected
Place blockBlock layoutVisible in region
No active promosAll promos expired/disabledBlock renders empty
Multiple promosCreate 3 active promosAll 3 display
Cache invalidationEdit promoBlock updates immediately
Time-based cacheWait for promo to expireBlock 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

php
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

php
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

php
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”

  1. Confirm component files exist:
    • components/promo_banner/promo_banner.component.yml
    • components/promo_banner/promo_banner.twig
    • components/promo_banner/promo_banner.css
  2. Clear cache:
bash
ddev drush cr
  1. Check template file name:
    • Must be: promo-kit-promo.html.twig
    • Location: templates/ directory

“Block shows expired promos”

  1. Check promo date ranges in admin UI
  2. Verify server timezone matches expected timezone
  3. Clear cache:
bash
ddev drush cr
  1. Check PromoManager query logic in getActivePromos()

“Validation doesn’t trigger”

  1. Confirm constraint files exist and are properly namespaced
  2. Clear cache twice (plugin discovery can be sticky):
bash
ddev drush cr
ddev drush cr
  1. Check entity type in constraint annotation: type: ['entity:promo_kit_promo']

“Permission denied errors”

  1. Grant appropriate permissions:
    • /admin/people/permissions
    • Search for “promo”
    • Grant permissions to appropriate roles
  2. Clear cache:
bash
ddev drush cr

15. 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_presave with #[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:

  1. Custom Content Entity with proper prefixing (promo_kit_promo)
  2. Service Layer (PromoManager) for business logic
  3. Query-based filtering (scalable, not loading all entities)
  4. Block Plugin with proper dependency injection
  5. Sophisticated caching (tags, contexts, dynamic max-age)
  6. Validation Constraints (proper UX)
  7. OO Hooks (modern attribute-based approach)
  8. 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_promo permission (and entity must be published)
  • Create access: Requires create promo_kit_promo permission
  • Update access: Requires edit promo_kit_promo permission
  • Delete access: Requires delete promo_kit_promo permission
  • Admin access: administer promo_kit_promo bypasses 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 promos
  • user_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:

bash
ddev drush cex -y

18.2 Shipping Default Configuration

Copy relevant YAML files from config/sync/ to config/install/:

bash
# 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:

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

20. Performance Considerations

20.1 Query Optimization

The PromoManager uses entity queries with conditions, not loading all entities:

Good (what we do):

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

Bad (don’t do this):

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

20.2 Caching Strategy

The block implements three caching dimensions:

  1. Cache Tags: Invalidate when content changes
  2. Cache Contexts: Vary by user permissions
  3. 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_id
  • langcode
  • status

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:

  1. Add base field in Promo::baseFieldDefinitions():
php
$fields['weight'] = BaseFieldDefinition::create('integer')
  ->setLabel(t('Weight'))
  ->setDescription(t('Lower weights appear first.'))
  ->setDefaultValue(0)
  ->setDisplayOptions('form', [
    'type' => 'number',
    'weight' => 20,
  ]);
  1. Update PromoManager query:
php
$query->sort('weight', 'ASC');

21.2 Adding Categories/Tags

To categorize promos:

  1. Add entity reference field:
php
$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'],
  ]);
  1. Create taxonomy vocabulary promo_categories
  2. Update block to filter by category (add block configuration)

21.3 Adding View Modes

To support different display styles:

  1. Create view mode via UI or config:
    • /admin/structure/display-modes/view/add/promo_kit_promo
  2. Configure fields for the view mode:
    • /admin/structure/promo-kit-promo/display/[view_mode]
  3. Use in block:
php
$build['#items'][] = $view_builder->view($promo, 'teaser');

21.4 Adding REST API Support

To expose promos via REST:

  1. Add dependency in promo_kit.info.yml:
yaml
dependencies:
- rest:rest
  1. Enable REST resource:
bash
ddev drush en rest -y
  1. Configure REST resource for promo_kit_promo entity type
  2. 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

  1. Enable module:
bash
drush en promo_kit -y
  1. Clear cache:
bash
drush cr
  1. Import configuration (if using config management):
bash
drush cim -y
  1. Run update hooks (if any):
bash
drush updb -y
  1. Verify permissions:
bash
drush role:perm:list authenticated
  1. Place block (if not in config):
    • Via UI: /admin/structure/block
    • Or via Drush: drush block:place promo_banner_block

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:

php
$query = $storage->getQuery();
$ids = $query->execute(); // Fatal error in D11

Solution:

php
$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:

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

Pitfall 3: Cache Not Invalidating

Problem: Block shows stale data after editing promo

Solution: Ensure cache tags are correct:

php
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):

bash
drush cr && drush cr

Pitfall 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

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

Appendix B: Key Drupal 11.2 Patterns Used

1. PHP 8 Attributes for Entity Definition

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

2. OO Hooks with Attributes

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

3. Validation Constraints

php
#[Constraint(
  id: 'PromoDateRange',
  label: new TranslatableMarkup('Promo Date Range'),
)]
class PromoDateRangeConstraint extends SymfonyConstraint { }

4. Constructor Property Promotion

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

5. Typed Properties

php
public string $message = 'The end date cannot be before the start date.';

6. Readonly Properties

php
protected readonly PromoManager $promoManager;

7. Mixed Type Hints

php
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.