Skip to main content

Short-form notes

Today I Learned

Things I run into as a Drupal & PHP developer — snippets, gotchas, and patterns worth writing down.

15 entries

I needed to override the ViewBuilder handler for media entities in a Drupal 11.x project — not subclass it via a custom entity type, but surgically replace the class used by an existing one. hook_entity_type_alter() is exactly the right tool. It receives every registered EntityTypeInterface object by reference after all modules have defined them, so you can call setHandlerClass() on any entity type before the container is compiled.

This is different from hook_entity_type_build(), which runs earlier and is meant for modules defining new types. hook_entity_type_alter() is the post-processing hook — think of it as the last-write-wins override point.

<?php

/**
 * Implements hook_entity_type_alter().
 */
function mymodule_entity_type_alter(array &$entity_types): void {
    if (isset($entity_types['media'])) {
        $entity_types['media']->setHandlerClass(
            'view_builder',
            \Drupal\mymodule\MediaViewBuilder::class
        );
    }
}

Make sure your replacement class extends the original handler (or at minimum implements the correct interface), and remember that the handler registry is cached — run drush cr after any change to this hook or Drupal will keep using the old class from the container build.

Drupal 11.4 (July 2026) introduces an experimental Symfony Console-based CLI at vendor/bin/dr, replacing the old hardcoded core/scripts/drupal entry point. It was built by a team that includes Drush maintainers — the stated plan is a gradual transition, not a hard cutover.

# Deprecated
php core/scripts/drupal generate-theme my_theme

# New
vendor/bin/dr generate-theme my_theme

Modules register commands by tagging a class in src/Command with Symfony's #[AsCommand] attribute — no services.yml wiring needed, unlike a classic Drush command. Still experimental, so I wouldn't migrate CI scripts to it yet, but it's worth writing new custom commands against it.

Before PHP 8.2, marking a DTO as immutable meant writing readonly on every single property. Now you can put it on the class itself and it applies to all instance properties automatically.

// PHP 8.1 — verbose
class UserDTO {
    public function __construct(
        public readonly string $name,
        public readonly string $email,
        public readonly int    $role,
    ) {}
}

// PHP 8.2 — one keyword covers all properties
readonly class UserDTO {
    public function __construct(
        public string $name,
        public string $email,
        public int    $role,
    ) {}
}

Two caveats: all properties must be typed (no dynamic properties allowed), and readonly cannot be used on enums, traits, or interfaces. Came up on a Drupal project where we pass config objects between services — cuts the noise considerably.

Previously Drupal's default robots.txt only blocked path-based search URLs like /search/keyword. Query-parameter searches (/search?q=keyword) stayed crawlable, which let search engines index infinite combinations of faceted search pages.

Disallow: /search?
Disallow: /index.php/search?

Only new installs and sites still using the default scaffold robots.txt get this automatically — if you've customized yours, Drupal won't touch it, and you have to add the rules by hand. If you're on Search API with facets instead of core search, double check your own query patterns are covered too; this change only targets core's /search path.

The #[Override] attribute tells PHP a method is intentionally overriding a parent. If the parent method is ever renamed or removed, PHP throws a fatal error at class load time — no more silent failures where your override quietly does nothing.

class Base {
    public function process(): void { /* ... */ }
}

class Child extends Base {
    #[Override]
    public function process(): void { /* safe */ }

    #[Override]
    public function prcess(): void { /* Fatal: typo caught immediately */ }
}

Hit the silent failure case several times during Drupal major version upgrades where a core hook or interface method was renamed between versions. This attribute would have caught those instantly.

File entities in Drupal aren't revisionable, so the Workspaces module has no mechanism to scope a file to a workspace the way it scopes a node. The moment a file is uploaded to public://, it's written to live storage in sites/default/files and served directly by the web server — no Drupal access check sits in that request path.

Practical effect: if an editor stages a node that references a brand-new image, the image itself is already publicly accessible at its live URL before the workspace is ever published. There is no "staged" version of a file. Keep that in mind for any workflow that assumes staged content is fully hidden until publish.

Before 8.3, validating a JSON string meant decoding it and checking json_last_error(). That parses and allocates the full structure even if you only care whether it is valid.

// old — allocates the entire decoded structure
$data = json_decode($input);
if (json_last_error() !== JSON_ERROR_NONE) { /* invalid */ }

// PHP 8.3 — validation only, no allocation
if (!json_validate($input)) { /* invalid */ }

Useful at API boundaries where you want a quick validity gate before passing the string deeper into the system. On a high-traffic Drupal REST endpoint we were decoding just to immediately re-encode — json_validate() let us short-circuit that.

The Drupal Storybook module needs Drush to generate *.stories.twig scaffolding for each component. The separate storybook-addon-sdc npm package skips that step — it reads a component's .component.yml and Twig template directly and builds the Storybook story dynamically.

npm install --save-dev storybook-addon-sdc

Useful when a frontend dev without a running Drupal/Drush environment needs to iterate on a component's markup and CSS in Storybook alone. For anything needing Drupal's own render pipeline (cache contexts, token replacement, access checks), fall back to the SDC Component Library module's /components route instead — that one runs inside Drupal, not beside it.

Drupal cache tags follow the pattern thing:identifier for specific items and thing_list for collections. Using the wrong one either over-invalidates (performance) or under-invalidates (stale data).

// Too broad — clears on every node save across the whole site
$build['#cache']['tags'][] = 'node_list';

// Better — clears only when article nodes change (Drupal 9.1+)
$build['#cache']['tags'][] = 'node_list:article';

// Best — clears only when this specific node changes
$build['#cache']['tags'][] = 'node:' . $node->id();

Use node_list:BUNDLE when rendering a listing of a specific content type. Reserve node_list for genuinely site-wide queries. The bundle-scoped variant was quietly added in Drupal 9.1 and is underused.

When a Drupal page is served from cache, the response includes an X-Drupal-Cache-Tags header listing every tag attached to that response. It is the fastest way to see what is driving cache clears without adding instrumentation.

curl -sI https://example.com/page | grep -i cache-tags
# X-Drupal-Cache-Tags: block_view config:block.block.search node:42 node_list user:0

If you see node_list on a page that renders a single node, something upstream is running a list query where a specific tag would do. If you expect a tag but it is missing, the render element is not declaring it — add it manually via $build['#cache']['tags']. Enable $settings['http.response.debug_cacheability_headers'] = TRUE; in settings.local.php to see these headers in development.

drush sql:sanitize scrubs a Drupal database of PII before sharing it — replaces user emails with safe placeholders and resets passwords. It should be a mandatory step before sending a production dump to any developer or staging environment.

drush @prod sql:dump > prod.sql
drush sql:drop && drush sql:cli < prod.sql
drush sql:sanitize --sanitize-password=drupal

By default it sets all user emails to user+[uid]@localhost. Extend it with hook_sanitize_sql_database() to scrub custom tables — important if you store personal data outside the core user table. Added this to every deployment pipeline we run at REEA.

drush watchdog:tail polls the Drupal watchdog and streams new entries to the terminal as they appear — like tail -f on a log file but for the database log.

# stream all entries
drush watchdog:tail

# filter by severity and channel
drush watchdog:tail --severity=error --type=php

# on a remote alias with full backtraces
drush @prod watchdog:tail --severity=notice --extended

Most useful when debugging a failing cron job or a Drush migration — run the operation in one terminal tab and watch the log stream in another. Much faster than repeatedly hitting the Reports UI or running drush watchdog:show.

drush php:eval executes a PHP snippet with full Drupal bootstrap — all services, entities, and APIs available — without writing a throwaway script file.

# inspect a config value on production
drush eval "print_r(\Drupal::config('system.site')->get('name'));"

# check a service is registered
drush eval "var_dump(\Drupal::hasService('my_module.processor'));"

# push an item onto a queue
drush eval "\Drupal::service('queue')->get('my_queue')->createItem(['nid' => 42]);"

I reach for this constantly during deployments to verify state after drush deploy — checking that config landed correctly, a service is wired, or a specific entity exists — all without touching the UI.

Config Split stores its own activation state inside config files. If you run drush cex while a split is active and commit the result, that split is baked into the repo as active — potentially enabling dev modules on production after the next deploy.

The safe pattern is to force activation through the config override system in settings.php, leaving the exported config always showing status: false.

// settings.local.php (development only)
$config['config_split.config_split.dev']['status'] = TRUE;

// settings.php (production — or just omit the override entirely)
$config['config_split.config_split.dev']['status'] = FALSE;

This way the repo never carries environment-specific activation state. The deployment artifact is clean and drush cim on any environment will do the right thing.

When you sync external data into Drupal — from an API, a migration, or a cron job — you often need to bust cached displays without performing a full entity save (which fires hooks, reindexes search, etc.). Cache tag invalidation lets you do this surgically.

use Drupal\Core\Cache\Cache;

// Static helper — hits every cache bin simultaneously
Cache::invalidateTags(['node:5', 'node_list:article']);

// Via the service container (useful inside injected services)
$this->cacheTagsInvalidator->invalidateTags(['product_feed']);

We use a custom product_feed tag on every block that renders external product data. After each API sync cron run we call Cache::invalidateTags(['product_feed']) and only those blocks re-render — nothing else is touched.