
Drupal At Your Fingertips
- 254 installs
- 73 repo stars
- Updated July 30, 2026
- grasmash/drupal-claude-skills
Give your coding agent synced Drupal 9+ chapter docs—from AJAX and actions to AI hooks—while you implement modules, themes, and site fixes.
About
Drupal at Your Fingertips is a bundled reference skill that mirrors Selwyn Polit’s Drupal at Your Fingertips book site into agent-readable chapters. Solo and indie builders who ship Drupal sites—custom modules, Views, forms, AJAX, and newer AI-related topics—invoke it when they want procedural context beside the editor rather than generic PHP/CMS guesses. Each section points to the live chapter for depth while summarizing what the topic covers: explanations, examples, best practices, and debugging notes. It fits the Build phase backend shelf but also supports Ship reviews and Operate fixes when behavior traces back to core APIs. Pair it with your project’s composer.lock, local Drupal version, and enabled modules so answers stay version-accurate. Treat it as documentation injection, not a deploy or test runner.
- 53-topic mirror of Drupal at Your Fingertips (upstream d9book), last verified 2025-10-31
- Per-chapter pointers to canonical online docs with code examples, patterns, and troubleshooting
- Coverage spans actions, AJAX, AI, and broader site-building chapters—not a single-task snippet
- Structured for agent retrieval instead of hunting drupalatyourfingertips.com tab-by-tab
- Maintained sync metadata (source URLs, author Selwyn Polit) for traceability
Drupal At Your Fingertips by the numbers
- 254 all-time installs (skills.sh)
- Ranked #1,514 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/grasmash/drupal-claude-skills --skill drupal-at-your-fingertipsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 254 |
|---|---|
| repo stars | ★ 73 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 30, 2026 |
| Repository | grasmash/drupal-claude-skills ↗ |
What it does
Give your coding agent synced Drupal 9+ chapter docs—from AJAX and actions to AI hooks—while you implement modules, themes, and site fixes.
Files
Drupal at Your Fingertips
Source: drupalatyourfingertips.com Author: Selwyn Polit License: Open access documentation
When This Skill Activates
Activates when working with Drupal development topics covered in the d9book including:
- Core APIs (services, hooks, events, plugins)
- Content (nodes, fields, entities, paragraphs, taxonomy)
- Forms and validation
- Routing and controllers
- Theming (Twig, render arrays, preprocess)
- Caching and performance
- Testing (PHPUnit, DTT)
- Common patterns and best practices
---
Available Topics
All topics are available as references in the /references/ directory.
Each reference links to the full chapter on drupalatyourfingertips.com with:
- Detailed explanations and code examples
- Best practices and common patterns
- Step-by-step guides
- Troubleshooting tips
Core Concepts
- @references/services.md - Dependency injection and service container
- @references/hooks.md - Hook system and implementations
- @references/events.md - Event subscribers and dispatchers
- @references/plugins.md - Plugin API and annotations
- @references/entities.md - Entity API and custom entities
Content Management
- @references/nodes-and-fields.md - Node and field API
- @references/forms.md - Form API and validation
- @references/paragraphs.md - Paragraphs module patterns
- @references/taxonomy.md - Taxonomy and vocabularies
- @references/menus.md - Menu system
Development Tools
- @references/composer.md - Dependency management
- @references/drush.md - Drush commands
- @references/debugging.md - Debugging techniques
- @references/logging.md - Logging and monitoring
- @references/dtt.md - Drupal Test Traits
Advanced Topics
- @references/batch.md - Batch API for long operations
- @references/queue.md - Queue API for background tasks
- @references/cron.md - Cron jobs and scheduling
- @references/ajax.md - AJAX framework
- @references/javascript.md - JavaScript in Drupal
See /references/ directory for complete list of 50+ topics.
---
---
To update: Run .claude/scripts/sync-d9book.sh
Last synced: 2025-10-31
Upstream: https://github.com/selwynpolit/d9book
Topics synced: 53
about
Source: Drupal at Your Fingertips - about Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/about
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
actions
Source: Drupal at Your Fingertips - actions Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/actions
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
ai
Source: Drupal at Your Fingertips - ai Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/ai
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
AJAX
Source: Drupal at Your Fingertips - ajax Author: Selwyn Polit
Quick reference for AJAX in Drupal with practical code examples.
---
Core Concept
AJAX in Drupal enables partial page updates without full page refreshes. Use the #ajax property on form elements to trigger callbacks that return AJAX commands (replace, append, remove, etc.) to manipulate the page.
AJAX in Forms
Basic pattern:
$form['category'] = [
'#type' => 'select',
'#title' => $this->t('Category'),
'#options' => ['tech' => 'Tech', 'news' => 'News'],
'#ajax' => [
'callback' => '::updateItems', // Method name
'wrapper' => 'items-wrapper', // HTML ID to replace
'event' => 'change', // Trigger event (default: change)
],
];
$form['items'] = [
'#type' => 'select',
'#title' => $this->t('Items'),
'#prefix' => '<div id="items-wrapper">',
'#suffix' => '</div>',
];AJAX callback:
public function updateItems(array &$form, FormStateInterface $form_state) {
// Return the element to replace
return $form['items'];
}---
AJAX Properties
Common #ajax properties:
| Property | Purpose | Default |
|---|---|---|
callback | Method to call | Required |
wrapper | HTML ID to replace | Required |
event | JavaScript event | 'change' |
method | HTTP method | 'POST' |
progress | Progress indicator | ['type' => 'throbber'] |
effect | jQuery effect | 'fade' |
speed | Effect speed | 'slow' |
Progress indicators:
'#ajax' => [
'callback' => '::myCallback',
'wrapper' => 'my-wrapper',
// Throbber (default)
'progress' => ['type' => 'throbber'],
// Full page progress bar
'progress' => ['type' => 'fullscreen'],
// Custom message
'progress' => [
'type' => 'throbber',
'message' => $this->t('Loading...'),
],
// No progress indicator
'progress' => ['type' => 'none'],
],---
AJAX Responses
Return render array (most common):
public function callback(array &$form, FormStateInterface $form_state) {
// Return element to replace
return $form['subcategory'];
}Return AjaxResponse with commands:
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Ajax\AppendCommand;
public function callback(array &$form, FormStateInterface $form_state) {
$response = new AjaxResponse();
// Replace element
$response->addCommand(new ReplaceCommand('#items-wrapper', $form['items']));
// Append content
$response->addCommand(new AppendCommand('#messages', '<div>New message</div>'));
return $response;
}---
AJAX Commands
Common AJAX commands:
ReplaceCommand - Replace element:
use Drupal\Core\Ajax\ReplaceCommand;
$response->addCommand(new ReplaceCommand('#selector', $content));AppendCommand - Append to element:
use Drupal\Core\Ajax\AppendCommand;
$response->addCommand(new AppendCommand('#selector', $content));PrependCommand - Prepend to element:
use Drupal\Core\Ajax\PrependCommand;
$response->addCommand(new PrependCommand('#selector', $content));RemoveCommand - Remove element:
use Drupal\Core\Ajax\RemoveCommand;
$response->addCommand(new RemoveCommand('#selector'));InvokeCommand - Call jQuery method:
use Drupal\Core\Ajax\InvokeCommand;
// Call .hide()
$response->addCommand(new InvokeCommand('#selector', 'hide'));
// Call .addClass('active')
$response->addCommand(new InvokeCommand('#selector', 'addClass', ['active']));
// Call .val('new value')
$response->addCommand(new InvokeCommand('#selector', 'val', ['new value']));HtmlCommand - Set HTML content:
use Drupal\Core\Ajax\HtmlCommand;
$response->addCommand(new HtmlCommand('#selector', '<p>New content</p>'));CssCommand - Change CSS:
use Drupal\Core\Ajax\CssCommand;
$response->addCommand(new CssCommand('#selector', ['background' => 'red']));AlertCommand - Show alert:
use Drupal\Core\Ajax\AlertCommand;
$response->addCommand(new AlertCommand('Alert message'));RedirectCommand - Redirect:
use Drupal\Core\Ajax\RedirectCommand;
$url = Url::fromRoute('my_module.page')->toString();
$response->addCommand(new RedirectCommand($url));---
Modal Dialogs
Open modal dialog:
use Drupal\Core\Ajax\OpenModalDialogCommand;
$title = $this->t('Modal Title');
$content = ['#markup' => '<p>Modal content</p>'];
$response->addCommand(new OpenModalDialogCommand($title, $content));Open dialog with options:
use Drupal\Core\Ajax\OpenDialogCommand;
$options = [
'width' => '50%',
'height' => 400,
'dialogClass' => 'my-custom-dialog',
];
$response->addCommand(new OpenDialogCommand('#dialog-selector', $title, $content, $options));Close dialog:
use Drupal\Core\Ajax\CloseDialogCommand;
$response->addCommand(new CloseDialogCommand());---
Custom AJAX Commands
Create custom command (PHP):
namespace Drupal\my_module\Ajax;
use Drupal\Core\Ajax\CommandInterface;
class ScrollToCommand implements CommandInterface {
protected $selector;
public function __construct($selector) {
$this->selector = $selector;
}
public function render() {
return [
'command' => 'scrollTo',
'selector' => $this->selector,
];
}
}JavaScript handler (my_module.ajax.js):
(function (Drupal, $) {
Drupal.AjaxCommands.prototype.scrollTo = function (ajax, response, status) {
var $element = $(response.selector);
if ($element.length) {
$('html, body').animate({
scrollTop: $element.offset().top
}, 500);
}
};
})(Drupal, jQuery);Attach library (my_module.libraries.yml):
ajax:
js:
js/my_module.ajax.js: {}
dependencies:
- core/drupal
- core/jqueryUse custom command:
use Drupal\my_module\Ajax\ScrollToCommand;
$response->addCommand(new ScrollToCommand('#target-element'));
$form['#attached']['library'][] = 'my_module/ajax';---
AJAX Links
Create AJAX-enabled link:
use Drupal\Core\Url;
$link = Link::createFromRoute(
$this->t('Load More'),
'my_module.load_more',
[],
[
'attributes' => [
'class' => ['use-ajax'],
],
]
);Route for AJAX link:
my_module.load_more:
path: '/load-more'
defaults:
_controller: '\Drupal\my_module\Controller\AjaxController::loadMore'
requirements:
_permission: 'access content'Controller:
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\AppendCommand;
public function loadMore(Request $request) {
if (!$request->isXmlHttpRequest()) {
throw new HttpException(400, 'This is only for AJAX requests');
}
$response = new AjaxResponse();
$content = [
'#theme' => 'item_list',
'#items' => $this->getMoreItems(),
];
$response->addCommand(new AppendCommand('#items-container', $content));
return $response;
}---
AJAX Validation
Validate before callback:
public function ajaxCallback(array &$form, FormStateInterface $form_state) {
// Check for errors
if ($form_state->hasAnyErrors()) {
// Return form to show validation errors
return $form;
}
// Proceed with AJAX logic
$value = $form_state->getValue('field_name');
// Build response
$response = new AjaxResponse();
$response->addCommand(new HtmlCommand('#result', "Value: $value"));
return $response;
}---
Multiple AJAX Commands
Chain multiple commands:
public function complexCallback(array &$form, FormStateInterface $form_state) {
$response = new AjaxResponse();
// 1. Replace content
$response->addCommand(new ReplaceCommand('#items', $form['items']));
// 2. Show message
$message = [
'#theme' => 'status_messages',
'#message_list' => ['status' => [$this->t('Updated successfully')]],
];
$response->addCommand(new PrependCommand('#content', $message));
// 3. Scroll to top
$response->addCommand(new InvokeCommand('html, body', 'animate', [
['scrollTop' => 0],
300,
]));
// 4. Add CSS class
$response->addCommand(new InvokeCommand('#items', 'addClass', ['updated']));
return $response;
}---
Debugging AJAX
Enable AJAX debugging:
// In browser console
Drupal.ajax.instances.forEach(function(instance) {
console.log(instance);
});Check AJAX requests in browser DevTools Network tab.
Add logging to callback:
public function ajaxCallback(array &$form, FormStateInterface $form_state) {
\Drupal::logger('my_module')->notice('AJAX callback triggered');
$values = $form_state->getValues();
\Drupal::logger('my_module')->notice('Values: @values', [
'@values' => print_r($values, TRUE),
]);
return $form['field'];
}---
---
Key Guidelines
✅ Use #ajax property - On form elements for callbacks ✅ Return render arrays - Simplest AJAX response ✅ Use AjaxResponse - For multiple commands ✅ Validate requests - Check isXmlHttpRequest() ✅ Add progress indicators - Improve UX ✅ Use wrapper IDs - Unique HTML IDs for targets ✅ Attach libraries - For custom JavaScript ✅ Test without JavaScript - Ensure graceful degradation
❌ Don't forget wrapper - Must have HTML ID ❌ Don't skip validation - Check request type ❌ Don't hardcode IDs - Use Html::getUniqueId() ❌ Don't forget #prefix/#suffix - For wrapper divs ❌ Don't mix AJAX types - Use consistent pattern ❌ Don't skip progress indicators - Show loading state ❌ Don't return NULL - Return valid render array or AjaxResponse
---
Full documentation: https://drupalatyourfingertips.com/ajax
attribution
Source: Drupal at Your Fingertips - attribution Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/attribution
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
blocks
Source: Drupal at Your Fingertips - blocks Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/blocks
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
bq
Source: Drupal at Your Fingertips - bq Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/bq
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
Caching
Source: Drupal at Your Fingertips - caching Author: Selwyn Polit
Quick reference for Drupal caching with practical code examples.
---
Core Concept
Drupal's caching system uses three components: cache tags (what data it depends on), cache contexts (how it varies), and max-age (how long it lasts). These must always be set together to ensure proper cacheability.
Cache Metadata Triplet
The three required components:
$build = [
'#markup' => 'Your content here',
'#cache' => [
'tags' => ['node:5', 'user:3'], // What invalidates this
'contexts' => ['user.roles', 'url.path'], // How it varies
'max-age' => 3600, // How long (seconds)
],
];| Component | Purpose | Example |
|---|---|---|
| Cache Tags | Dependencies - invalidate when data changes | node:5, user:3, custom_list |
| Cache Contexts | Variations - create different versions | user, url.query_args:id, languages |
| Max-Age | Lifetime - how long before stale | 3600, Cache::PERMANENT, 0 |
---
Cache Tags
Format: thing:identifier or just thing
use Drupal\Core\Cache\Cache;
// Entity cache tags
$cache_tags = [
'node:' . $node->id(),
'user:' . $user->id(),
'node_list',
'user_list',
];
// Get entity cache tags (recommended)
$node_tags = $node->getCacheTags();
$user_tags = $user->getCacheTags();Invalidate cache tags:
use Drupal\Core\Cache\Cache;
// Invalidate specific tags
Cache::invalidateTags(['node:5', 'custom_tag_example']);
// Invalidate when entity changes
function my_module_node_update(NodeInterface $node) {
Cache::invalidateTags($node->getCacheTags());
}---
Cache Contexts
Hierarchical variations (more specific encompasses less specific):
// User contexts (hierarchical)
'contexts' => ['user'], // Varies per user
'contexts' => ['user.roles'], // Varies per role set
'contexts' => ['user.roles:authenticated'], // Only for authenticated
// URL contexts
'contexts' => ['url.path'], // Varies per path
'contexts' => ['url.query_args'], // Varies per any query arg
'contexts' => ['url.query_args:id'], // Only varies on ?id=
// Language contexts
'contexts' => ['languages:language_interface'],Common cache contexts:
| Context | Use Case |
|---|---|
user | Per-user content |
user.roles | Per-role visibility |
user.permissions | Per-permission access |
url.path | Different for each path |
url.query_args | Query parameter variations |
languages | Multilingual content |
theme | Theme-specific rendering |
---
Cache Max-Age
Lifetime control:
use Drupal\Core\Cache\Cache;
// Permanent caching
$build['#cache']['max-age'] = Cache::PERMANENT;
// Time-based expiration (seconds)
$build['#cache']['max-age'] = 3600; // 1 hour
$build['#cache']['max-age'] = 86400; // 1 day
// Disable caching
$build['#cache']['max-age'] = 0;
// Calculate expiration from timestamp
$expiration_time = strtotime('+1 hour');
$current_time = \Drupal::time()->getRequestTime();
$build['#cache']['max-age'] = max(0, $expiration_time - $current_time);---
Render Array Caching
Basic pattern:
public function build() {
$node = Node::load(5);
return [
'#theme' => 'my_template',
'#node' => $node,
'#cache' => [
'tags' => $node->getCacheTags(),
'contexts' => ['user.roles'],
'max-age' => Cache::PERMANENT,
],
];
}Multiple cache tags:
public function buildList() {
$nodes = Node::loadMultiple([1, 2, 3]);
$cache_tags = ['node_list'];
foreach ($nodes as $node) {
$cache_tags = Cache::mergeTags($cache_tags, $node->getCacheTags());
}
return [
'#theme' => 'node_list',
'#nodes' => $nodes,
'#cache' => [
'tags' => $cache_tags,
'contexts' => ['url.query_args:page'],
'max-age' => 3600,
],
];
}---
JSON Response Caching
Use CacheableJsonResponse:
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Cache\Cache;
public function apiEndpoint() {
$data = ['status' => 'success', 'items' => $this->getItems()];
$response = new CacheableJsonResponse($data, 200, [
'Cache-Control' => 'public, max-age=3600',
]);
// Add cache metadata
$response->getCacheableMetadata()
->addCacheTags(['custom_api_data'])
->addCacheContexts(['url.query_args:filter'])
->setCacheMaxAge(3600);
return $response;
}Add cacheable dependency:
use Drupal\Core\Cache\CacheableMetadata;
public function configBasedApi() {
$config = $this->config('my_module.settings');
$response = new CacheableJsonResponse(['data' => $config->get('api_data')]);
// Automatically adds config's cache tags
$response->addCacheableDependency($config);
return $response;
}---
Page Cache Kill Switch
Prevent page caching (but allow browser/CDN caching):
public function dynamicPage() {
// Disable Drupal's page cache
\Drupal::service('page_cache_kill_switch')->trigger();
return [
'#markup' => 'Dynamic content: ' . time(),
];
}When to use: For pages with per-request dynamic content that can't use cache contexts.
---
Cache Bins
Default bins:
| Bin | Purpose |
|---|---|
default | General cache data |
bootstrap | Early bootstrap data |
render | Rendered elements |
data | Module-specific data |
discovery | Plugin discovery |
Use cache bins:
// Get cache bin
$cache = \Drupal::cache('render');
// Set cache item
$cache->set('my_cache_id', $data, time() + 3600, ['custom_tag']);
// Get cache item
$cached = $cache->get('my_cache_id');
if ($cached) {
$data = $cached->data;
}
// Delete cache item
$cache->delete('my_cache_id');Custom cache bin (my_module.services.yml):
services:
cache.voting:
class: Drupal\Core\Cache\CacheBackendInterface
tags:
- { name: cache.bin }
factory: cache_factory:get
arguments: [voting]// Use custom bin
\Drupal::cache('voting')->set($id, $votes, Cache::PERMANENT, ['vote_list']);---
Cache-Friendly Architecture
Pattern: Static page + dynamic API:
// Controller returns cacheable page
public function buildPage() {
return [
'#theme' => 'counter_page',
'#attached' => [
'library' => ['my_module/counter'],
],
'#cache' => [
'tags' => ['counter_page'],
'contexts' => [],
'max-age' => Cache::PERMANENT,
],
];
}
// Separate API endpoint for dynamic data
public function counterApi() {
$count = $this->getCurrentCount();
$response = new CacheableJsonResponse(['count' => $count]);
$response->getCacheableMetadata()
->setCacheMaxAge(60); // 1 minute
return $response;
}JavaScript fetches dynamic data:
fetch('/api/counter/endpoint')
.then(response => response.json())
.then(data => {
document.querySelector('.count').textContent = data.count;
});---
Cache Debugging
Enable debug headers (sites/development.services.yml):
parameters:
http.response.debug_cacheability_headers: trueInspect cache headers (browser DevTools Network tab):
X-Drupal-Cache-Tags: node:5 node_list user:3
X-Drupal-Cache-Contexts: user.roles url.path
X-Drupal-Cache-Max-Age: 3600Disable render cache (sites/default/settings.local.php):
$settings['cache']['bins']['render'] = 'cache.backend.null';
$settings['cache']['bins']['page'] = 'cache.backend.null';
$settings['cache']['bins']['dynamic_page_cache'] = 'cache.backend.null';---
Cache Backends
Memcached (settings.php):
$settings['memcache']['servers'] = ['memcached:11211' => 'default'];
$settings['cache']['default'] = 'cache.backend.memcache';Redis (settings.php):
$settings['redis.connection']['interface'] = 'PhpRedis';
$settings['redis.connection']['host'] = 'redis';
$settings['cache']['default'] = 'cache.backend.redis';APCu (single server only):
$settings['cache']['default'] = 'cache.backend.apcu';---
---
Key Guidelines
✅ Always set cache metadata triplet - tags, contexts, max-age together ✅ Use getCacheTags() - Don't hardcode entity tag format ✅ Use cache contexts - Instead of disabling caching ✅ Use CacheableJsonResponse - For API endpoints ✅ Invalidate precisely - Use specific cache tags ✅ Use cache bins - Separate different cache types ✅ Enable debug headers - During development ✅ Use Cache::mergeTags() - When combining tags
❌ Don't skip cache metadata - All three required ❌ Don't use page_cache_kill_switch casually - Use contexts instead ❌ Don't hardcode entity cache tags - Use getCacheTags() ❌ Don't forget addCacheableDependency - For config/entity deps ❌ Don't use max-age 0 by default - Architect for caching ❌ Don't invalidate too broadly - Be specific with tags ❌ Don't cache per-request data - Use appropriate max-age
---
Full documentation: https://drupalatyourfingertips.com/caching
composer
Source: Drupal at Your Fingertips - composer Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/composer
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
Configuration Management
Source: Drupal at Your Fingertips - config Author: Selwyn Polit
Quick reference for Drupal configuration management with practical code examples.
---
Core Concept
Configuration in Drupal lives in YAML files (version control) and the database (active config). The Configuration API provides a standardized way to read, write, import, and export settings. Config files use the pattern modulename.something.yml.
Reading Configuration
In PHP code:
// Read config value
$config = \Drupal::config('system.site');
$site_name = $config->get('name');
$site_mail = $config->get('mail');
// Read nested value
$endpoint = \Drupal::config('my_module.settings')
->get('api.endpoint');Via Drush:
# Get config value
drush cget system.site name
drush cget shield.settings credentials.shield.pass
# Include overridden values from settings.php
drush cget system.site name --include-overridden---
Writing Configuration
In PHP code (use getEditable):
// Write config value
\Drupal::configFactory()
->getEditable('my_module.settings')
->set('api_key', 'abc123')
->save();
// Write multiple values
$config = \Drupal::configFactory()->getEditable('my_module.settings');
$config->set('api.endpoint', 'https://api.example.com')
->set('api.timeout', 30)
->save();
// Clear specific value
$config->clear('old_setting')->save();Via Drush:
# Set config value
drush cset system.site name "My Site Name"
drush cset shield.settings credentials.shield.pass secretpass---
Configuration Forms
Extend ConfigFormBase for settings:
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
class SettingsForm extends ConfigFormBase {
protected function getEditableConfigNames() {
return ['my_module.settings'];
}
public function getFormId() {
return 'my_module_settings_form';
}
public function buildForm(array $form, FormStateInterface $form_state) {
$config = $this->config('my_module.settings');
$form['api_key'] = [
'#type' => 'textfield',
'#title' => $this->t('API Key'),
'#default_value' => $config->get('api_key'),
'#required' => TRUE,
];
$form['api_endpoint'] = [
'#type' => 'url',
'#title' => $this->t('API Endpoint'),
'#default_value' => $config->get('api_endpoint'),
];
return parent::buildForm($form, $form_state);
}
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->config('my_module.settings')
->set('api_key', $form_state->getValue('api_key'))
->set('api_endpoint', $form_state->getValue('api_endpoint'))
->save();
parent::submitForm($form, $form_state);
}
}---
Altering Existing Config Forms
Add fields to core forms:
function my_module_form_system_site_information_settings_alter(&$form, FormStateInterface $form_state) {
$config = \Drupal::config('system.site');
$form['site_phone'] = [
'#type' => 'tel',
'#title' => t('Site phone'),
'#default_value' => $config->get('phone'),
];
// Add custom submit handler
$form['#submit'][] = 'my_module_system_site_phone_submit';
}
function my_module_system_site_phone_submit(array &$form, FormStateInterface $form_state) {
\Drupal::configFactory()
->getEditable('system.site')
->set('phone', $form_state->getValue('site_phone'))
->save();
}---
Configuration Storage
Config sync directory (settings.php):
// Default location
$settings['config_sync_directory'] = '../config/sync';
// Custom location
$settings['config_sync_directory'] = '/var/www/config';Module config directories:
| Directory | Purpose | Behavior |
|---|---|---|
config/install | Installed with module | Module install fails if config fails |
config/optional | Installed if dependencies exist | Module installs regardless |
config/schema | Defines config structure | Used for validation |
---
Configuration Overrides
Override in settings.php (environment-specific):
// Override site name
$config['system.site']['name'] = 'Dev Site';
$config['system.site']['mail'] = 'dev@example.com';
// Override performance settings
$config['system.performance']['css']['preprocess'] = FALSE;
$config['system.performance']['js']['preprocess'] = FALSE;
// Override shield credentials
$config['shield.settings']['credentials']['shield']['user'] = 'admin';
$config['shield.settings']['credentials']['shield']['pass'] = 'password';Environment-specific (settings.local.php):
// Disable CSS/JS aggregation for development
$config['system.performance']['css']['preprocess'] = FALSE;
$config['system.performance']['js']['preprocess'] = FALSE;
// Disable page caching
$config['system.performance']['cache']['page']['max_age'] = 0;---
Import/Export Configuration
Export all config:
# Export to sync directory
drush cex -y
# Check status before export
drush cst
# Export specific module
drush cex --destination=/tmp/configImport config:
# Import all from sync directory
drush cim -y
# Import from specific directory
drush config-import --source=modules/custom/my_module/config/install/ --partial -y
# Check import status
drush cstCommon workflow:
# 1. Check what changed
drush cst
# 2. Export changes
drush cex -y
# 3. Commit to Git
git add config/
git commit -m "Export config changes"
# 4. On another environment
git pull
drush cim -y---
Post-Update Hooks
Modify config during updates (my_module.post_update.php):
<?php
/**
* Change site name.
*/
function my_module_post_update_change_site_name() {
\Drupal::configFactory()
->getEditable('system.site')
->set('name', 'My New Site Name')
->save();
}
/**
* Update API endpoint.
*/
function my_module_post_update_api_endpoint() {
\Drupal::configFactory()
->getEditable('my_module.settings')
->set('api.endpoint', 'https://new-api.example.com')
->save();
}Run post-updates:
drush updb -y---
Configuration Read-Only Mode
Protect production config (settings.php):
// Enable read-only mode in production
if (isset($_ENV['AH_SITE_ENVIRONMENT']) && $_ENV['AH_SITE_ENVIRONMENT'] === 'prod') {
$settings['config_readonly'] = TRUE;
}
// Whitelist specific config
$settings['config_readonly_whitelist_patterns'] = [
'system.menu.main*',
'webform.webform.*',
'block.block.*',
];---
Site UUID Management
View/set site UUID:
# View UUID
drush cget system.site uuid
# Set UUID (for config sync)
drush cset system.site uuid 1234567890Override in settings.php:
$config['system.site']['uuid'] = '1234567890';Why important: Config imports require matching UUIDs between files and active site.
---
Dependency Injection for Config
Inject config factory:
use Drupal\Core\Config\ConfigFactoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
class MyService {
protected ConfigFactoryInterface $configFactory;
public static function create(ContainerInterface $container) {
return new static(
$container->get('config.factory')
);
}
public function __construct(ConfigFactoryInterface $config_factory) {
$this->configFactory = $config_factory;
}
public function getApiKey() {
return $this->configFactory
->get('my_module.settings')
->get('api_key');
}
}---
Config Entities vs Simple Config
Simple config - Settings, key-value pairs:
# my_module.settings.yml
api_key: 'abc123'
api_endpoint: 'https://api.example.com'
timeout: 30Config entities - Exportable content types, views, blocks:
# views.view.my_view.yml
id: my_view
label: 'My View'
module: views
display:
default:
# ...---
Debugging Configuration
List all config:
# List all config names
drush config:status --state=Identical
# Search config
drush config:status | grep shieldView raw config:
# View entire config object
drush config:get system.site
# View as YAML
drush config:get system.site --format=yamlCheck config sync status:
# Compare database vs files
drush config:status
# Show differences
drush config:status --state=Different---
---
Key Guidelines
✅ Use ConfigFormBase - For settings forms ✅ Use getEditable() - For writing config ✅ Use get() only - For reading config (immutable) ✅ Export config to Git - Always version control ✅ Use post-update hooks - For config changes during updates ✅ Override in settings.php - For environment-specific values ✅ Use config:status - Check before import/export ✅ Inject config.factory - Use DI in services ✅ Backup database - Before config imports
❌ Don't use get()->set() - Use getEditable() instead ❌ Don't edit config/install - Export/import instead ❌ Don't hardcode settings - Use config API ❌ Don't skip exports - Keep files in sync ❌ Don't modify database directly - Use config API ❌ Don't forget UUID - Must match for imports ❌ Don't use static calls in classes - Inject dependencies
---
Full documentation: https://drupalatyourfingertips.com/config
contribute
Source: Drupal at Your Fingertips - contribute Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/contribute
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
cron
Source: Drupal at Your Fingertips - cron Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/cron
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
dates
Source: Drupal at Your Fingertips - dates Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/dates
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
debugging
Source: Drupal at Your Fingertips - debugging Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/debugging
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
decoupled
Source: Drupal at Your Fingertips - decoupled Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/decoupled
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
development
Source: Drupal at Your Fingertips - development Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/development
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
drush
Source: Drupal at Your Fingertips - drush Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/drush
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
Drupal Test Traits (DTT)
Source: Drupal at Your Fingertips - dtt Author: Selwyn Polit
Quick reference for Drupal Test Traits (DTT) with practical code examples.
---
Core Concept
DTT enables fast functional testing against existing Drupal sites without database recreation. Tests use real site data and run significantly faster than traditional PHPUnit tests.
Installation
Composer dependencies:
composer require weitzman/drupal-test-traits:^2 --dev
composer require drupal/core-dev --dev --update-with-all-dependencies
composer require weitzman/logintrait --dev---
ExistingSite Test Structure
Basic test (tests/src/ExistingSite/ExampleTest.php):
namespace Drupal\Tests\my_module\ExistingSite;
use weitzman\DrupalTestTraits\ExistingSiteBase;
class ExampleTest extends ExistingSiteBase {
public function testBasicFunctionality() {
// Test code here
$this->assertTrue(TRUE);
}
}Run tests:
vendor/bin/phpunit docroot/modules/custom/my_module/tests/---
Entity Creation & Cleanup
DTT automatically cleans up created entities:
public function testNodeCreation() {
// Create node
$node = $this->createNode([
'type' => 'article',
'title' => 'Test Article',
'status' => 1,
]);
$this->assertEquals('Test Article', $node->getTitle());
// Node auto-deleted after test
}Create user:
public function testUserCreation() {
$user = $this->createUser(
['access content'], // Permissions
'testuser' // Username
);
$this->assertEquals('testuser', $user->getAccountName());
}---
User Authentication
Login as existing user:
public function testAsAuthenticatedUser() {
$user = User::load(1);
$user->passRaw = 'admin'; // Required for login
$this->drupalLogin($user);
$this->drupalGet('/admin/config');
$this->assertSession()->statusCodeEquals(200);
}Login as new user:
public function testAsNewUser() {
$user = $this->createUser(['access content']);
$this->drupalLogin($user);
$this->drupalGet('/node/add/article');
$this->assertSession()->statusCodeEquals(403);
}---
Page Navigation & Assertions
Navigate and verify:
public function testPageAccess() {
$this->drupalGet('/node/1');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('Expected text');
$this->assertSession()->pageTextNotContains('Unexpected text');
}Form submission:
public function testFormSubmission() {
$this->drupalGet('/user/login');
$this->submitForm([
'name' => 'admin',
'pass' => 'password',
], 'Log in');
$this->assertSession()->addressEquals('/user/1');
}---
JavaScript Tests
ExistingSiteJavascript (for AJAX, modals, dynamic content):
use weitzman\DrupalTestTraits\ExistingSiteSelenium2DriverTestBase;
class JavascriptTest extends ExistingSiteSelenium2DriverTestBase {
public function testAjaxInteraction() {
$this->drupalGet('/my-ajax-page');
$page = $this->getSession()->getPage();
$page->clickLink('Load More');
// Wait for AJAX
$this->assertSession()->assertWaitOnAjaxRequest();
$this->assertSession()->pageTextContains('New content');
}
}---
Common Assertions
| Method | Purpose |
|---|---|
statusCodeEquals(200) | Check HTTP status |
page TextContains('text') | Verify text on page |
pageTextNotContains('text') | Ensure text absent |
addressEquals('/path') | Check current URL |
fieldValueEquals('name', 'value') | Check form field |
buttonExists('label') | Verify button presence |
linkExists('label') | Verify link presence |
---
Data Providers
Run same test with multiple datasets:
public function providerTestData(): array {
return [
['article', 200],
['page', 200],
['nonexistent', 404],
];
}
/**
* @dataProvider providerTestData
*/
public function testContentTypes(string $type, int $expected_status) {
$this->drupalGet("/node/add/$type");
$this->assertSession()->statusCodeEquals($expected_status);
}---
Debugging
Capture HTML output:
public function testWithDebug() {
$this->drupalGet('/problematic-page');
// Save HTML to browser_output directory
$this->capturePageContent();
$this->assertSession()->statusCodeEquals(200);
}Screenshot (JavaScript tests):
use weitzman\DrupalTestTraits\ScreenShotTrait;
public function testWithScreenshot() {
$this->drupalGet('/page');
// Save screenshot
$this->captureScreenshot();
$this->assertSession()->statusCodeEquals(200);
}Configure output directory (phpunit.xml):
<env name="DTT_HTML_OUTPUT_DIRECTORY" value="sites/simpletest/browser_output"/>---
---
phpunit.xml Configuration
Required environment variables:
<phpunit>
<php>
<env name="DTT_BASE_URL" value="https://example.ddev.site"/>
<env name="DTT_MINK_DRIVER_ARGS" value='["chrome", {"browserName": "chrome"}, "http://chromedriver:4444/wd/hub"]'/>
<env name="DTT_HTML_OUTPUT_DIRECTORY" value="sites/simpletest/browser_output"/>
</php>
<testsuites>
<testsuite name="existing-site">
<directory>./docroot/modules/custom/*/tests/src/ExistingSite</directory>
</testsuite>
</testsuites>
</phpunit>---
Key Guidelines
✅ Use ExistingSite for API tests - Faster, no JavaScript needed ✅ Use ExistingSiteJavascript for AJAX - Browser automation ✅ Let DTT clean up - Entities auto-deleted after tests ✅ Test with real data - Use actual site content ✅ Use data providers - Test multiple scenarios ✅ Capture debugging output - HTML/screenshots for failures ✅ Test permissions - Verify access control
❌ Don't manually delete entities - DTT handles cleanup ❌ Don't recreate test database - DTT uses existing site ❌ Don't skip assertions - Always verify expected behavior ❌ Don't forget passRaw - Required for existing user login ❌ Don't use JavaScript tests unnecessarily - Slower execution
---
Full documentation: https://drupalatyourfingertips.com/dtt
Source: Drupal at Your Fingertips - email Author: Selwyn Polit
---
Full Documentation
View online:
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
Entities
Source: Drupal at Your Fingertips - entities Author: Selwyn Polit
Quick reference for Drupal Entity API with practical code examples.
---
Core Concept
Entities are structured data objects in Drupal. Content entities (nodes, users, taxonomy terms) are fieldable and translatable. Config entities (views, content types) store configuration.
Loading Entities
By ID (using entity class):
$node = \Drupal\node\Entity\Node::load(123);
$user = \Drupal\user\Entity\User::load(1);
$term = \Drupal\taxonomy\Entity\Term::load(5);Multiple by IDs:
$nodes = \Drupal\node\Entity\Node::loadMultiple([1, 2, 3]);By property (returns array):
$nodes = \Drupal::entityTypeManager()
->getStorage('node')
->loadByProperties(['title' => 'My Article']);By UUID (required for your project APIs):
$entities = \Drupal::entityTypeManager()
->getStorage('node')
->loadByProperties(['uuid' => $uuid]);
$node = !empty($entities) ? reset($entities) : NULL;---
Entity Queries
Basic query:
$storage = \Drupal::entityTypeManager()->getStorage('node');
$query = $storage->getQuery()
->accessCheck(FALSE) // Explicitly disable access checks
->condition('type', 'article')
->condition('status', 1)
->sort('created', 'DESC')
->range(0, 10);
$nids = $query->execute();
$nodes = $storage->loadMultiple($nids);With relationships:
$query = $storage->getQuery()
->accessCheck(TRUE)
->condition('field_category.entity.name', 'Technology');Count query:
$count = $storage->getQuery()
->accessCheck(FALSE)
->condition('type', 'article')
->count()
->execute();IMPORTANT: Always explicitly specify accessCheck(TRUE) or accessCheck(FALSE).
---
Creating Entities
Preferred method (using entity class):
$node = \Drupal\node\Entity\Node::create([
'type' => 'article',
'title' => 'My New Article',
'body' => [
'value' => '<p>Article content</p>',
'format' => 'basic_html',
],
'field_tags' => [1, 2, 3], // Term IDs
'status' => 1,
'uid' => \Drupal::currentUser()->id(),
]);
$node->save();With entity references:
$node = Node::create([
'type' => 'article',
'title' => 'Article with Image',
'field_image' => [
'target_id' => $file->id(),
'alt' => 'Image alt text',
'title' => 'Image title',
],
]);
$node->save();---
Updating Entities
Simple field update:
$node = Node::load(123);
$node->set('title', 'Updated Title');
$node->set('field_category', $term->id());
$node->save();Multi-value field:
$node->field_tags->setValue([
['target_id' => 1],
['target_id' => 2],
['target_id' => 3],
]);
$node->save();Append to multi-value field:
$node->field_tags[] = ['target_id' => 4];
$node->save();---
Deleting Entities
Single delete:
$node = Node::load(123);
$node->delete();Bulk delete:
$storage = \Drupal::entityTypeManager()->getStorage('node');
$entities = $storage->loadByProperties(['type' => 'article']);
$storage->delete($entities);IMPORTANT: Use hook_ENTITY_TYPE_predelete() to clean up related data.
---
Field Access & Manipulation
Get field value:
// Simple field
$title = $node->get('title')->value;
$status = $node->get('status')->value;
// Entity reference
$category_id = $node->get('field_category')->target_id;
$category = $node->get('field_category')->entity;
// Multi-value field
foreach ($node->get('field_tags') as $item) {
$term_id = $item->target_id;
$term = $item->entity;
}Check if field has value:
if (!$node->get('field_image')->isEmpty()) {
$image_url = $node->get('field_image')->entity->uri->value;
}Get referenced entity:
$author = $node->get('uid')->entity; // User entity
$category = $node->get('field_category')->entity; // Term entity---
Entity Type & Bundle Checks
Check entity type:
if ($entity instanceof \Drupal\Core\Entity\ContentEntityInterface) {
// It's a content entity
}
if ($entity->getEntityTypeId() === 'node') {
// It's a node
}Check bundle:
if ($entity->getEntityTypeId() === 'node' && $entity->bundle() === 'article') {
// It's an article node
}---
Common Entity Methods
| Method | Purpose | Returns |
|---|---|---|
id() | Get entity ID | Integer |
uuid() | Get UUID | String |
label() | Get entity label | String |
bundle() | Get bundle (e.g., 'article') | String |
isNew() | Check if unsaved | Boolean |
save() | Persist to database | Integer |
delete() | Remove from database | Void |
get($field) | Get field object | FieldItemList |
set($field, $value) | Set field value | $this |
hasField($field) | Check field exists | Boolean |
toArray() | Export field values | Array |
getEntityTypeId() | Get type (e.g., 'node') | String |
access($op, $account) | Check access | Boolean |
---
---
Entity Validation
Add field constraints:
function gg_example_entity_bundle_field_info_alter(&$fields, EntityTypeInterface $entity_type, $bundle) {
if ($entity_type->id() === 'node' && $bundle === 'song') {
if (!empty($fields['field_difficulty'])) {
$fields['field_difficulty']->setPropertyConstraints('value', [
'Range' => ['min' => 1, 'max' => 5],
]);
}
}
}Validate before save:
$violations = $node->validate();
if ($violations->count() > 0) {
foreach ($violations as $violation) {
\Drupal::messenger()->addError($violation->getMessage());
}
return;
}
$node->save();---
Dependency Injection Pattern
In controllers/services:
use Drupal\Core\Entity\EntityTypeManagerInterface;
class MyController extends ControllerBase {
protected EntityTypeManagerInterface $entityTypeManager;
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager')
);
}
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}
public function buildList() {
$storage = $this->entityTypeManager->getStorage('node');
$nodes = $storage->loadByProperties(['type' => 'article']);
// ...
}
}---
Key Guidelines
✅ Use entity classes - Node::load() over entity_load('node') ✅ Always specify accessCheck - Explicitly set TRUE or FALSE ✅ Use UUIDs for APIs - Never expose internal IDs ✅ Load by properties for UUID - loadByProperties(['uuid' => $uuid]) ✅ Check isEmpty() - Before accessing field values ✅ Use DI in classes - Inject entity_type.manager service ✅ Validate before save - Use $entity->validate()
❌ Don't use entity_load() - Deprecated, use entity classes ❌ Don't skip accessCheck - Required in Drupal 10+ ❌ Don't expose internal IDs - Security risk in APIs ❌ Don't assume field exists - Use hasField() first ❌ Don't forget to save - Changes aren't persisted until save()
---
Full documentation: https://drupalatyourfingertips.com/entities
Events & Event Subscribers
Source: Drupal at Your Fingertips - events Author: Selwyn Polit
Quick reference for Drupal events and event subscribers with practical code examples.
---
Core Concept
Events allow modules to react to actions without modifying code. Event subscribers listen for dispatched events and execute custom logic when events occur.
Event Subscriber Structure
Basic event subscriber:
namespace Drupal\my_module\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class MySubscriber implements EventSubscriberInterface {
public static function getSubscribedEvents() {
return [
KernelEvents::REQUEST => ['onRequest', 100],
];
}
public function onRequest(RequestEvent $event) {
// React to the request event
\Drupal::logger('my_module')->notice('Request received');
}
}Register in my_module.services.yml:
services:
my_module.subscriber:
class: Drupal\my_module\EventSubscriber\MySubscriber
tags:
- { name: event_subscriber }---
Event Subscriber with DI
Inject services:
use Drupal\Core\Session\AccountProxyInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
class MySubscriber implements EventSubscriberInterface {
protected AccountProxyInterface $currentUser;
public function __construct(AccountProxyInterface $current_user) {
$this->currentUser = $current_user;
}
public static function create(ContainerInterface $container) {
return new static(
$container->get('current_user')
);
}
public static function getSubscribedEvents() {
return [
KernelEvents::REQUEST => ['onRequest'],
];
}
public function onRequest(RequestEvent $event) {
if ($this->currentUser->isAuthenticated()) {
// Do something for logged-in users
}
}
}Service definition with DI:
services:
my_module.subscriber:
class: Drupal\my_module\EventSubscriber\MySubscriber
arguments: ['@current_user']
tags:
- { name: event_subscriber }---
Event Priorities
Priority order (higher numbers run first):
public static function getSubscribedEvents() {
return [
KernelEvents::REQUEST => [
['onRequestEarly', 100], // Runs first
['onRequestLate', -100], // Runs last
],
];
}Default priority is 0 if not specified.
---
Commonly Used Events
| Event | Constant | Use Case |
|---|---|---|
| Request | KernelEvents::REQUEST | Early request processing |
| Response | KernelEvents::RESPONSE | Modify response before sending |
| Controller | KernelEvents::CONTROLLER | Before controller execution |
| View | KernelEvents::VIEW | Before rendering |
| Exception | KernelEvents::EXCEPTION | Error handling |
| Terminate | KernelEvents::TERMINATE | After response sent |
| Config Save | ConfigEvents::SAVE | Configuration changes |
| Config Delete | ConfigEvents::DELETE | Configuration removal |
| Entity Insert | MyEvents::ENTITY_INSERT | After entity creation |
---
KernelEvents Examples
Modify request:
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
public static function getSubscribedEvents() {
return [KernelEvents::REQUEST => ['onRequest']];
}
public function onRequest(RequestEvent $event) {
$request = $event->getRequest();
// Add custom request attribute
$request->attributes->set('custom_data', 'value');
// Redirect based on condition
if ($some_condition) {
$response = new RedirectResponse('/custom-page');
$event->setResponse($response);
}
}Modify response:
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
public static function getSubscribedEvents() {
return [KernelEvents::RESPONSE => ['onResponse']];
}
public function onResponse(ResponseEvent $event) {
$response = $event->getResponse();
// Add custom header
$response->headers->set('X-Custom-Header', 'MyValue');
// Modify content
$content = $response->getContent();
$content = str_replace('old', 'new', $content);
$response->setContent($content);
}---
Custom Events
Define custom event:
namespace Drupal\my_module\Event;
use Symfony\Contracts\EventDispatcher\Event;
use Drupal\node\NodeInterface;
class NodeSaveEvent extends Event {
const EVENT_NAME = 'my_module.node_save';
protected NodeInterface $node;
public function __construct(NodeInterface $node) {
$this->node = $node;
}
public function getNode() {
return $this->node;
}
}Dispatch custom event:
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
class MyService {
protected EventDispatcherInterface $eventDispatcher;
public function __construct(EventDispatcherInterface $event_dispatcher) {
$this->eventDispatcher = $event_dispatcher;
}
public function saveNode(NodeInterface $node) {
$node->save();
// Dispatch custom event
$event = new NodeSaveEvent($node);
$this->eventDispatcher->dispatch($event, NodeSaveEvent::EVENT_NAME);
}
}Subscribe to custom event:
use Drupal\my_module\Event\NodeSaveEvent;
public static function getSubscribedEvents() {
return [
NodeSaveEvent::EVENT_NAME => ['onNodeSave'],
];
}
public function onNodeSave(NodeSaveEvent $event) {
$node = $event->getNode();
\Drupal::logger('my_module')->notice('Node @title saved', [
'@title' => $node->getTitle(),
]);
}---
Stop Propagation
Prevent other subscribers from executing:
public function onRequest(RequestEvent $event) {
if ($some_condition) {
// Stop other subscribers from running
$event->stopPropagation();
// Set custom response
$response = new JsonResponse(['error' => 'Access denied'], 403);
$event->setResponse($response);
}
}---
---
Key Guidelines
✅ Use high priorities - For early intervention (100+) ✅ Use low priorities - For final modifications (-100) ✅ Inject dependencies - Use DI in subscribers ✅ Tag services - Always add event_subscriber tag ✅ Stop propagation - When needed to prevent later execution ✅ Create custom events - For module-specific actions ✅ Use constants - Define event names as class constants
❌ Don't use static calls - Inject services instead ❌ Don't forget service tags - Won't work without tag ❌ Don't overuse high priorities - Can break other modules ❌ Don't modify immutable data - Some event properties are read-only ❌ Don't use events for hooks - Use hooks when available
---
Full documentation: https://drupalatyourfingertips.com/events
Forms
Source: Drupal at Your Fingertips - forms Author: Selwyn Polit
Quick reference for Drupal Form API with practical code examples.
---
Core Concept
Forms in Drupal are render arrays with validation and submission handlers. Forms extend FormBase or ConfigFormBase and implement three required methods: getFormId(), buildForm(), and submitForm().
Basic Form Structure
Minimal form class:
namespace Drupal\my_module\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
class ExampleForm extends FormBase {
public function getFormId() {
return 'my_module_example_form';
}
public function buildForm(array $form, FormStateInterface $form_state) {
$form['name'] = [
'#type' => 'textfield',
'#title' => $this->t('Your Name'),
'#required' => TRUE,
];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Submit'),
];
return $form;
}
public function submitForm(array &$form, FormStateInterface $form_state) {
$name = $form_state->getValue('name');
\Drupal::messenger()->addStatus($this->t('Hello @name!', ['@name' => $name]));
}
}---
Common Form Elements
| Element Type | Purpose | Example |
|---|---|---|
textfield | Single-line text | Name, email (no validation) |
email | Email with validation | Email address |
number | Numeric input | Age, quantity |
select | Dropdown | Category selection |
radios | Radio buttons | Single choice |
checkboxes | Multiple checkboxes | Multi-select |
checkbox | Single checkbox | Accept terms |
textarea | Multi-line text | Description, body |
password | Password field | Credentials |
date | Date picker | Birth date |
submit | Submit button | Save, Update |
Element example:
$form['quantity'] = [
'#type' => 'number',
'#title' => $this->t('Quantity'),
'#min' => 0,
'#max' => 100,
'#default_value' => 1,
'#required' => TRUE,
'#description' => $this->t('Enter quantity (0-100)'),
];
$form['category'] = [
'#type' => 'select',
'#title' => $this->t('Category'),
'#options' => [
'tech' => $this->t('Technology'),
'news' => $this->t('News'),
'sports' => $this->t('Sports'),
],
'#empty_option' => $this->t('- Select -'),
];---
Form Validation
Add validation method:
public function validateForm(array &$form, FormStateInterface $form_state) {
$name = $form_state->getValue('name');
if (strlen($name) < 3) {
$form_state->setErrorByName('name',
$this->t('Name must be at least 3 characters.')
);
}
$email = $form_state->getValue('email');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$form_state->setErrorByName('email',
$this->t('Invalid email address.')
);
}
}Validation runs before submit - If validateForm() sets errors, submitForm() won't execute.
---
Form Submission
Access submitted values:
public function submitForm(array &$form, FormStateInterface $form_state) {
// Get single value
$name = $form_state->getValue('name');
// Get all values
$values = $form_state->getValues();
// Create entity
$node = Node::create([
'type' => 'article',
'title' => $values['title'],
'body' => $values['body'],
]);
$node->save();
// Show message
$this->messenger()->addStatus($this->t('Article created.'));
// Redirect
$form_state->setRedirect('entity.node.canonical', ['node' => $node->id()]);
}---
Dependency Injection in Forms
Inject services:
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
class ExampleForm extends FormBase {
protected EntityTypeManagerInterface $entityTypeManager;
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager')
);
}
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
$this->entityTypeManager = $entity_type_manager;
}
public function buildForm(array $form, FormStateInterface $form_state) {
$storage = $this->entityTypeManager->getStorage('node');
// Use storage...
}
}---
Conditional Fields (#states)
Show/hide based on other fields:
$form['show_advanced'] = [
'#type' => 'checkbox',
'#title' => $this->t('Show advanced options'),
];
$form['advanced'] = [
'#type' => 'textarea',
'#title' => $this->t('Advanced Settings'),
'#states' => [
'visible' => [
':input[name="show_advanced"]' => ['checked' => TRUE],
],
],
];Multiple conditions:
$form['field'] = [
'#type' => 'textfield',
'#states' => [
'visible' => [
':input[name="type"]' => ['value' => 'custom'],
],
'required' => [
':input[name="type"]' => ['value' => 'custom'],
],
],
];---
AJAX in Forms
Add AJAX to button:
$form['category'] = [
'#type' => 'select',
'#title' => $this->t('Category'),
'#options' => ['tech' => 'Tech', 'news' => 'News'],
'#ajax' => [
'callback' => '::updateSubcategory',
'wrapper' => 'subcategory-wrapper',
'event' => 'change',
],
];
$form['subcategory'] = [
'#type' => 'select',
'#title' => $this->t('Subcategory'),
'#prefix' => '<div id="subcategory-wrapper">',
'#suffix' => '</div>',
];
public function updateSubcategory(array &$form, FormStateInterface $form_state) {
return $form['subcategory'];
}---
Configuration Forms
For storing settings:
use Drupal\Core\Form\ConfigFormBase;
class SettingsForm extends ConfigFormBase {
protected function getEditableConfigNames() {
return ['my_module.settings'];
}
public function getFormId() {
return 'my_module_settings_form';
}
public function buildForm(array $form, FormStateInterface $form_state) {
$config = $this->config('my_module.settings');
$form['api_key'] = [
'#type' => 'textfield',
'#title' => $this->t('API Key'),
'#default_value' => $config->get('api_key'),
];
return parent::buildForm($form, $form_state);
}
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->config('my_module.settings')
->set('api_key', $form_state->getValue('api_key'))
->save();
parent::submitForm($form, $form_state);
}
}---
Altering Forms
In .module file:
function my_module_form_alter(&$form, FormStateInterface $form_state, $form_id) {
if ($form_id === 'node_article_edit_form') {
$form['title']['widget'][0]['value']['#required'] = FALSE;
$form['body']['#access'] = FALSE;
}
}
function my_module_form_user_login_form_alter(&$form, FormStateInterface $form_state) {
$form['actions']['submit']['#value'] = $this->t('Sign In');
}---
---
Translatable Strings
Always use $this->t():
$form['name'] = [
'#type' => 'textfield',
'#title' => $this->t('Name'),
'#description' => $this->t('Enter your full name.'),
];
$this->messenger()->addStatus(
$this->t('Welcome @name!', ['@name' => $name])
);---
Key Guidelines
✅ Use form classes - Extend FormBase or ConfigFormBase ✅ Validate early - Implement validateForm() for input checking ✅ Use #states - For conditional fields without custom JS ✅ Inject services - Use DI for entity storage, config ✅ Translate strings - Always use $this->t() ✅ Use #required - For mandatory fields ✅ Set redirects - Use $form_state->setRedirect()
❌ Don't process in buildForm - Only build, don't save data ❌ Don't skip validation - Always validate user input ❌ Don't hardcode strings - Use translation functions ❌ Don't use static calls in classes - Use DI instead ❌ Don't forget error messages - Use setErrorByName()
---
Full documentation: https://drupalatyourfingertips.com/forms
general
Source: Drupal at Your Fingertips - general Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/general
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
Hooks
Source: Drupal at Your Fingertips - hooks Author: Selwyn Polit
Quick reference for Drupal hook system with practical code examples.
---
Core Concept
Hooks allow modules to alter and extend Drupal core or other modules without modifying their code. Implement hooks in .module files using naming convention {module_name}_{hook_name}.
Form Hooks
Target specific form:
function my_module_form_user_login_form_alter(&$form, FormStateInterface $form_state) {
$form['actions']['submit']['#value'] = t('Sign In');
}Target all forms:
function my_module_form_alter(&$form, FormStateInterface $form_state, $form_id) {
if ($form_id === 'node_event_edit_form') {
$form['title']['widget'][0]['value']['#required'] = FALSE;
}
}Get form ID: Inspect $form['#form_id'] or use Devel module.
---
Entity Hooks
Before save:
function my_module_node_presave(NodeInterface $node) {
if ($node->getType() === 'article') {
// Auto-populate fields, validate data
$node->set('field_updated', time());
}
}After save:
function my_module_node_insert(NodeInterface $node) {
// Send notifications, clear caches
\Drupal::service('messenger')->addStatus(t('New @type created.', [
'@type' => $node->getType(),
]));
}
function my_module_node_update(NodeInterface $node) {
// Handle updates differently than inserts
}Before delete:
function my_module_node_predelete(NodeInterface $node) {
// Clean up related data
\Drupal::database()->delete('my_custom_table')
->condition('nid', $node->id())
->execute();
}---
Entity Lifecycle Order
Save sequence: 1. preSave() method on entity 2. hook_ENTITY_TYPE_presave(), hook_entity_presave() 3. Storage operation (insert/update) 4. hook_ENTITY_TYPE_insert() or hook_ENTITY_TYPE_update() 5. hook_ENTITY_TYPE_insert(), hook_entity_insert()
Delete sequence: 1. hook_ENTITY_TYPE_predelete(), hook_entity_predelete() 2. Storage deletion 3. hook_ENTITY_TYPE_delete(), hook_entity_delete()
---
Update Hooks
Database changes (run via drush updb):
function my_module_update_9001() {
$schema = \Drupal::database()->schema();
if (!$schema->indexExists('node_field_data', 'idx_type_created')) {
$schema->addIndex('node_field_data', 'idx_type_created', ['type', 'created']);
}
return t('Added index for better performance.');
}Batch operations:
function my_module_update_9002(&$sandbox) {
if (!isset($sandbox['progress'])) {
$sandbox['progress'] = 0;
$sandbox['max'] = \Drupal::entityQuery('node')
->condition('type', 'article')
->count()
->execute();
}
$nids = \Drupal::entityQuery('node')
->condition('type', 'article')
->range($sandbox['progress'], 25)
->execute();
$nodes = \Drupal\node\Entity\Node::loadMultiple($nids);
foreach ($nodes as $node) {
$node->set('field_migrated', TRUE);
$node->save();
$sandbox['progress']++;
}
$sandbox['#finished'] = $sandbox['progress'] / $sandbox['max'];
}Numbering: Use 9001+, increment by 1. Track in key_value table.
---
Theme Hooks
Preprocess variables for templates:
function my_theme_preprocess_node(&$variables) {
$node = $variables['elements']['#node'];
// Add custom variables
$variables['custom_date'] = \Drupal::service('date.formatter')
->format($node->getCreatedTime(), 'custom', 'M d, Y');
// Modify render arrays
$variables['content']['field_image']['#suffix'] = '<p class="caption">Image caption</p>';
}Preprocessing order: 1. template_preprocess() 2. template_preprocess_HOOK() 3. MODULE_preprocess() 4. MODULE_preprocess_HOOK() 5. THEME_preprocess() 6. THEME_preprocess_HOOK()
Access in Twig: {{ custom_date }}
---
Commonly Used Hooks
| Hook | Purpose | File Location |
|---|---|---|
hook_form_alter | Modify any form | .module |
hook_form_FORM_ID_alter | Modify specific form | .module |
hook_entity_presave | Before entity save | .module |
hook_entity_insert | After entity create | .module |
hook_entity_update | After entity update | .module |
hook_entity_delete | After entity delete | .module |
hook_preprocess_HOOK | Prepare template vars | .theme |
hook_update_N | Database updates | .install |
hook_install | Module installation | .install |
hook_uninstall | Module removal | .install |
hook_theme | Register templates | .module |
---
---
OOP Approach (Advanced)
For complex hook logic, use service classes:
// In my_module.services.yml
services:
my_module.form_handler:
class: Drupal\my_module\FormHandler
arguments: ['@current_user', '@entity_type.manager']
// In src/FormHandler.php
class FormHandler implements ContainerInjectionInterface {
protected $currentUser;
protected $entityTypeManager;
public function __construct(AccountProxyInterface $current_user, EntityTypeManagerInterface $entity_type_manager) {
$this->currentUser = $current_user;
$this->entityTypeManager = $entity_type_manager;
}
public static function create(ContainerInterface $container) {
return new static(
$container->get('current_user'),
$container->get('entity_type.manager')
);
}
public function alterLoginForm(&$form, FormStateInterface $form_state) {
// Complex logic with injected services
}
}
// In my_module.module
function my_module_form_user_login_form_alter(&$form, FormStateInterface $form_state) {
\Drupal::service('my_module.form_handler')->alterLoginForm($form, $form_state);
}Benefits: Testable, uses DI, cleaner code organization.
---
Finding Available Hooks
Documentation locations:
- Each module's
*.api.phpfile (e.g.,node.api.php,views.api.php) - Drupal API documentation
- Search: "hook_" in
/core/lib/Drupal/Core/*.api.php
Common hook patterns:
hook_{entity_type}_{operation}- Entity operationshook_form_{FORM_ID}_alter- Specific form modificationshook_preprocess_{HOOK}- Template preprocessinghook_{module}_{action}- Module-specific actions
---
Key Guidelines
✅ Use specific hooks - hook_form_FORM_ID_alter over hook_form_alter ✅ Type-specific entity hooks - hook_node_presave over hook_entity_presave ✅ Clear cache - Run drush cr after adding new hooks ✅ Update hooks - Number sequentially, use batch for large datasets ✅ Document complex logic - Add comments explaining why, not what
❌ Don't modify core - Always use hooks instead ❌ Don't use generic hooks unnecessarily - Impacts performance ❌ Don't forget hook_update_N return - Always return message string
---
Full documentation: https://drupalatyourfingertips.com/hooks
index
Source: Drupal at Your Fingertips - index Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/index
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
javascript
Source: Drupal at Your Fingertips - javascript Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/javascript
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
layoutbuilder
Source: Drupal at Your Fingertips - layoutbuilder Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/layoutbuilder
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
learn
Source: Drupal at Your Fingertips - learn Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/learn
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
links
Source: Drupal at Your Fingertips - links Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/links
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
logging
Source: Drupal at Your Fingertips - logging Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/logging
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
menus
Source: Drupal at Your Fingertips - menus Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/menus
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
migrate
Source: Drupal at Your Fingertips - migrate Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/migrate
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
modals
Source: Drupal at Your Fingertips - modals Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/modals
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
modules
Source: Drupal at Your Fingertips - modules Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/modules
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
mysteries
Source: Drupal at Your Fingertips - mysteries Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/mysteries
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
nodes-and-fields
Source: Drupal at Your Fingertips - nodes-and-fields Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/nodes-and-fields
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
off-island
Source: Drupal at Your Fingertips - off-island Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/off-island
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
Paragraphs
Source: Drupal at Your Fingertips - paragraphs Author: Selwyn Polit
Quick reference for Drupal Paragraphs module with practical code examples.
---
Core Concept
Paragraphs are reusable content components embedded within nodes. They function like mini-entities with their own fields, allowing flexible page layouts by mixing different paragraph types (text, image, video, etc.) in a single field. Uses Entity Reference Revisions for versioning.
Creating Paragraph Types
Via UI: 1. Navigate to /admin/structure/paragraphs_type 2. Add paragraph type (e.g., "Text Block", "Image + Caption", "Call to Action") 3. Add fields to the paragraph type 4. Configure display modes
Common paragraph types:
- Text block (body field)
- Image with caption (image + text)
- Video embed (video URL + description)
- Call to action (title + link + button style)
- Accordion item (title + body + collapsed state)
---
Adding Paragraphs Field to Content Type
Field settings:
Field type: Entity reference revisions
Reference type: Paragraph
Allowed paragraph types: Select which types
Number of values: UnlimitedImportant: Use Entity Reference Revisions field type, not standard Entity Reference.
---
Programmatic Access
Load paragraphs from node:
use Drupal\paragraphs\Entity\Paragraph;
$node = Node::load(123);
// Get paragraph field
$paragraphs = $node->get('field_paragraphs')->referencedEntities();
foreach ($paragraphs as $paragraph) {
// Get paragraph type
$type = $paragraph->getType();
// Access paragraph fields
if ($type === 'text_block') {
$title = $paragraph->get('field_title')->value;
$body = $paragraph->get('field_body')->value;
}
if ($type === 'image_caption') {
$image = $paragraph->get('field_image')->entity;
$caption = $paragraph->get('field_caption')->value;
}
}Access specific paragraph:
// Get first paragraph
$first_paragraph = $node->get('field_paragraphs')->first()->entity;
// Get paragraph by index
$second_paragraph = $node->get('field_paragraphs')[1]->entity;
// Check if paragraph exists
if (!$node->get('field_paragraphs')->isEmpty()) {
$paragraph = $node->get('field_paragraphs')->first()->entity;
}---
Entity Reference Revisions
Important distinction:
// CORRECT - Use target_revision_id for paragraphs
$revision_id = $node->get('field_paragraphs')[0]->target_revision_id;
// WRONG - Don't use target_id alone
$id = $node->get('field_paragraphs')[0]->target_id; // ❌
// WRONG - Don't use .value
$value = $node->get('field_paragraphs')[0]->value; // Returns NULLLoad by revision ID:
use Drupal\paragraphs\Entity\Paragraph;
$revision_id = $node->get('field_paragraphs')[0]->target_revision_id;
$paragraph = Paragraph::load($revision_id);---
Creating Paragraphs Programmatically
Create and attach paragraph:
use Drupal\paragraphs\Entity\Paragraph;
// Create paragraph
$paragraph = Paragraph::create([
'type' => 'text_block',
'field_title' => 'My Title',
'field_body' => [
'value' => '<p>My content</p>',
'format' => 'full_html',
],
]);
$paragraph->save();
// Attach to node
$node = Node::load(123);
$node->get('field_paragraphs')->appendItem($paragraph);
$node->save();Create multiple paragraphs:
$paragraphs = [];
// Create text paragraph
$text_paragraph = Paragraph::create([
'type' => 'text_block',
'field_title' => 'Section 1',
'field_body' => ['value' => 'Content...', 'format' => 'full_html'],
]);
$text_paragraph->save();
$paragraphs[] = $text_paragraph;
// Create image paragraph
$image_paragraph = Paragraph::create([
'type' => 'image_caption',
'field_image' => ['target_id' => $file_id],
'field_caption' => 'Image caption',
]);
$image_paragraph->save();
$paragraphs[] = $image_paragraph;
// Attach all to node
$node->set('field_paragraphs', $paragraphs);
$node->save();---
Nested Paragraphs
Paragraph within paragraph:
// Create inner paragraph
$inner = Paragraph::create([
'type' => 'text_block',
'field_title' => 'Inner Content',
]);
$inner->save();
// Create outer paragraph with reference to inner
$outer = Paragraph::create([
'type' => 'container',
'field_paragraphs' => [$inner], // Reference inner paragraph
]);
$outer->save();
// Access nested paragraphs
$outer_paragraph = $node->get('field_paragraphs')->first()->entity;
$inner_paragraphs = $outer_paragraph->get('field_paragraphs')->referencedEntities();---
Updating Paragraphs
Update existing paragraph:
$paragraph = $node->get('field_paragraphs')->first()->entity;
$paragraph->set('field_title', 'Updated Title');
$paragraph->save();
// Node doesn't need to be re-saved for paragraph updatesReplace paragraph:
// Remove old paragraph
$old_paragraph = $node->get('field_paragraphs')[0]->entity;
// Create new paragraph
$new_paragraph = Paragraph::create([
'type' => 'text_block',
'field_title' => 'New Content',
]);
$new_paragraph->save();
// Replace in node
$node->get('field_paragraphs')[0] = $new_paragraph;
$node->save();---
Rendering Paragraphs
In Twig templates:
{# Render all paragraphs #}
{{ content.field_paragraphs }}
{# Render specific paragraph #}
{{ content.field_paragraphs.0 }}
{# Loop through paragraphs #}
{% for paragraph in node.field_paragraphs %}
<div class="paragraph paragraph--{{ paragraph.entity.bundle }}">
{{ paragraph.entity.field_title.value }}
{{ paragraph.entity.field_body.value|raw }}
</div>
{% endfor %}
{# Check paragraph type #}
{% for paragraph in node.field_paragraphs %}
{% if paragraph.entity.bundle == 'text_block' %}
<div class="text-block">
{{ paragraph.entity.field_body.value|raw }}
</div>
{% elseif paragraph.entity.bundle == 'image_caption' %}
<figure>
<img src="{{ paragraph.entity.field_image.entity.uri.value|file_url }}" />
<figcaption>{{ paragraph.entity.field_caption.value }}</figcaption>
</figure>
{% endif %}
{% endfor %}---
Accessing Entity References in Paragraphs
Taxonomy terms:
$paragraph = $node->get('field_paragraphs')->first()->entity;
// Get referenced terms
$terms = $paragraph->get('field_tags')->referencedEntities();
foreach ($terms as $term) {
$term_name = $term->label();
$term_id = $term->id();
}In Twig:
{% for paragraph in node.field_paragraphs %}
{% for tag in paragraph.entity.field_tags %}
<span class="tag">{{ tag.entity.label }}</span>
{% endfor %}
{% endfor %}---
Validation
Validate paragraph fields:
function my_module_form_alter(&$form, FormStateInterface $form_state, $form_id) {
if ($form_id === 'node_article_edit_form') {
$form['#validate'][] = 'my_module_validate_paragraphs';
}
}
function my_module_validate_paragraphs(array &$form, FormStateInterface $form_state) {
$paragraphs = $form_state->getValue('field_paragraphs');
foreach ($paragraphs as $delta => $item) {
if (isset($item['subform'])) {
// Validate paragraph field
$title = $item['subform']['field_title'][0]['value'];
if (empty($title)) {
$form_state->setErrorByName(
"field_paragraphs][$delta][subform][field_title",
t('Title is required.')
);
}
}
}
}---
Deleting Paragraphs
Remove paragraph from node:
$node = Node::load(123);
// Remove first paragraph
$node->get('field_paragraphs')->removeItem(0);
$node->save();
// Remove all paragraphs
$node->set('field_paragraphs', []);
$node->save();Delete paragraph entity:
$paragraph = Paragraph::load($paragraph_id);
$paragraph->delete();---
View Modes for Paragraphs
Configure view modes at /admin/structure/display-modes/view
Render with specific view mode:
$view_builder = \Drupal::entityTypeManager()->getViewBuilder('paragraph');
$paragraph = $node->get('field_paragraphs')->first()->entity;
$build = $view_builder->view($paragraph, 'teaser');
return $build;---
Query Paragraphs
Find nodes with specific paragraph type:
$query = \Drupal::entityQuery('node')
->condition('type', 'article')
->accessCheck(FALSE);
// Join to paragraph field table
$query->condition('field_paragraphs.entity.type', 'text_block');
$nids = $query->execute();---
---
Key Guidelines
✅ Use Entity Reference Revisions - Required field type ✅ Use target_revision_id - For paragraph references ✅ Use referencedEntities() - To load paragraphs ✅ Create logical paragraph types - Text, image, video, etc. ✅ Use view modes - For different display contexts ✅ Save paragraph before attaching - Call $paragraph->save() ✅ Check paragraph type - Before accessing type-specific fields ✅ Use paragraphs for flexibility - Better than fixed layouts
❌ Don't use target_id alone - Use target_revision_id ❌ Don't use .value on references - Returns NULL ❌ Don't forget to save paragraphs - Before attaching to nodes ❌ Don't hardcode paragraph order - Allow reordering in UI ❌ Don't nest too deeply - Max 2-3 levels ❌ Don't mix paragraph types carelessly - Group related types ❌ Don't skip validation - Validate required fields
---
Full documentation: https://drupalatyourfingertips.com/paragraphs
php
Source: Drupal at Your Fingertips - php Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/php
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
Plugins
Source: Drupal at Your Fingertips - plugins Author: Selwyn Polit
Quick reference for Drupal plugin system with practical code examples.
---
Core Concept
Plugins are reusable, swappable components for similar functionality (blocks, field formatters, field widgets). Modules define plugin types, and other modules provide implementations via annotations.
Basic Plugin Structure
Block plugin example (src/Plugin/Block/ExampleBlock.php):
namespace Drupal\my_module\Plugin\Block;
use Drupal\Core\Block\BlockBase;
/**
* Provides an example block.
*
* @Block(
* id = "my_module_example",
* admin_label = @Translation("Example Block"),
* category = @Translation("Custom")
* )
*/
class ExampleBlock extends BlockBase {
public function build() {
return [
'#markup' => $this->t('Hello from custom block!'),
];
}
}---
Plugin with Configuration
Configurable block:
/**
* @Block(
* id = "configurable_block",
* admin_label = @Translation("Configurable Block")
* )
*/
class ConfigurableBlock extends BlockBase {
public function defaultConfiguration() {
return [
'message' => '',
];
}
public function blockForm($form, FormStateInterface $form_state) {
$form['message'] = [
'#type' => 'textfield',
'#title' => $this->t('Message'),
'#default_value' => $this->configuration['message'],
];
return $form;
}
public function blockSubmit($form, FormStateInterface $form_state) {
$this->configuration['message'] = $form_state->getValue('message');
}
public function build() {
return [
'#markup' => $this->configuration['message'],
];
}
}---
Plugin with Dependency Injection
Inject services:
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Session\AccountProxyInterface;
/**
* @Block(
* id = "user_info_block",
* admin_label = @Translation("User Info Block")
* )
*/
class UserInfoBlock extends BlockBase implements ContainerFactoryPluginInterface {
protected AccountProxyInterface $currentUser;
public static function create(
ContainerInterface $container,
array $configuration,
$plugin_id,
$plugin_definition
) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('current_user')
);
}
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
AccountProxyInterface $current_user
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->currentUser = $current_user;
}
public function build() {
return [
'#markup' => $this->t('Welcome @name', [
'@name' => $this->currentUser->getAccountName(),
]),
];
}
}---
Common Plugin Types
| Type | Base Class | Purpose |
|---|---|---|
| Block | BlockBase | Blocks for layout |
| Field Formatter | FormatterBase | Display field values |
| Field Widget | WidgetBase | Edit field values |
| Field Type | FieldItemBase | Custom field storage |
| Action | ActionBase | Bulk operations |
| Condition | ConditionPluginBase | Boolean conditions |
| Filter | FilterBase | Text filters |
---
Custom Field Type
Field type (stores the data):
/**
* @FieldType(
* id = "color",
* label = @Translation("Color"),
* default_widget = "color_widget",
* default_formatter = "color_formatter"
* )
*/
class ColorItem extends FieldItemBase {
public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
$properties['value'] = DataDefinition::create('string')
->setLabel(t('Color value'));
return $properties;
}
public static function schema(FieldStorageDefinitionInterface $field_definition) {
return [
'columns' => [
'value' => [
'type' => 'varchar',
'length' => 7,
],
],
];
}
public function isEmpty() {
$value = $this->get('value')->getValue();
return $value === NULL || $value === '';
}
}Field widget:
/**
* @FieldWidget(
* id = "color_widget",
* label = @Translation("Color picker"),
* field_types = {"color"}
* )
*/
class ColorWidget extends WidgetBase {
public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
$element['value'] = $element + [
'#type' => 'color',
'#default_value' => $items[$delta]->value ?? '#000000',
];
return $element;
}
}Field formatter:
/**
* @FieldFormatter(
* id = "color_formatter",
* label = @Translation("Color swatch"),
* field_types = {"color"}
* )
*/
class ColorFormatter extends FormatterBase {
public function viewElements(FieldItemListInterface $items, $langcode) {
$elements = [];
foreach ($items as $delta => $item) {
$elements[$delta] = [
'#markup' => '<div style="background-color: ' . $item->value . '; width: 50px; height: 50px;"></div>',
];
}
return $elements;
}
}---
Plugin Discovery
List all plugins of a type:
$plugin_manager = \Drupal::service('plugin.manager.block');
$definitions = $plugin_manager->getDefinitions();
foreach ($definitions as $plugin_id => $definition) {
echo $plugin_id . ': ' . $definition['admin_label'] . "\n";
}Instantiate plugin:
$plugin_manager = \Drupal::service('plugin.manager.block');
$config = ['message' => 'Hello'];
$instance = $plugin_manager->createInstance('my_module_example', $config);
$build = $instance->build();---
Plugin Derivatives
Create multiple plugins from one class:
namespace Drupal\my_module\Plugin\Derivative;
use Drupal\Component\Plugin\Derivative\DeriverBase;
class MenuBlockDeriver extends DeriverBase {
public function getDerivativeDefinitions($base_plugin_definition) {
$menus = \Drupal::entityTypeManager()->getStorage('menu')->loadMultiple();
foreach ($menus as $menu_id => $menu) {
$this->derivatives[$menu_id] = $base_plugin_definition;
$this->derivatives[$menu_id]['admin_label'] = $menu->label() . ' menu';
}
return $this->derivatives;
}
}Use in annotation:
/**
* @Block(
* id = "menu_block",
* admin_label = @Translation("Menu block"),
* deriver = "Drupal\my_module\Plugin\Derivative\MenuBlockDeriver"
* )
*/---
Debugging Plugins
List available plugins:
drush ev 'dump(\Drupal::service("plugin.manager.block")->getDefinitions());'Generate plugin code:
drush generate plugin:block
drush generate plugin:field:type
drush generate plugin:field:widget
drush generate plugin:field:formatter---
---
Key Guidelines
✅ Use annotations - Document plugin metadata ✅ Extend base classes - Use framework-provided bases ✅ Implement interfaces - ContainerFactoryPluginInterface for DI ✅ Use derivatives - For dynamic plugin sets ✅ Cache access - Implement getCacheContexts/Tags/MaxAge ✅ Generate code - Use drush generate for scaffolding ✅ Follow naming - Plugin ID = module_name.suffix
❌ Don't hardcode - Use configuration for variable data ❌ Don't skip validation - Validate configuration input ❌ Don't forget caching - Implement cache methods ❌ Don't use static calls - Inject dependencies ❌ Don't duplicate - Use derivatives instead of similar plugins
---
Full documentation: https://drupalatyourfingertips.com/plugins
Database Queries
Source: Drupal at Your Fingertips - queries Author: Selwyn Polit
Quick reference for Drupal database queries with practical code examples.
---
Core Concept
Entity queries are the primary method for database operations in Drupal. They provide an abstraction layer over SQL, are database-agnostic, and integrate with Drupal's entity system. Always use accessCheck() to explicitly enable or disable access checks.
Basic Entity Query
Standard pattern:
$storage = \Drupal::entityTypeManager()->getStorage('node');
$query = $storage->getQuery()
->accessCheck(FALSE) // REQUIRED in Drupal 9.3+
->condition('type', 'article')
->condition('status', 1)
->sort('created', 'DESC')
->range(0, 10);
$nids = $query->execute();Load results:
$nids = $query->execute();
if (!empty($nids)) {
$nodes = $storage->loadMultiple($nids);
}---
Access Checks
REQUIRED in Drupal 9.3+:
// Disable access checks (admin/system operations)
$query->accessCheck(FALSE);
// Enable access checks (user-facing operations)
$query->accessCheck(TRUE);
// Default behavior (fails in Drupal 9.3+)
$query; // ❌ Must explicitly set accessCheck---
Query Conditions
Condition operators:
| Operator | Purpose | Example |
|---|---|---|
= | Equals (default) | ->condition('status', 1) |
<> | Not equals | ->condition('field_text', '', '<>') |
>, >=, <, <= | Comparison | ->condition('field_value', 10, '>') |
IN | In array | ->condition('type', ['article', 'page'], 'IN') |
NOT IN | Not in array | ->condition('type', ['test'], 'NOT IN') |
BETWEEN | Between values | ->condition('created', [$start, $end], 'BETWEEN') |
IS NULL | Is null | ->condition('field_ref', NULL, 'IS NULL') |
IS NOT NULL | Is not null | ->condition('field_ref', NULL, 'IS NOT NULL') |
CONTAINS | Contains string | ->condition('title', 'test', 'CONTAINS') |
STARTS_WITH | Starts with | ->condition('title', 'The', 'STARTS_WITH') |
ENDS_WITH | Ends with | ->condition('title', 'End', 'ENDS_WITH') |
Common patterns:
// Published nodes
->condition('status', 1)
// Specific content type
->condition('type', 'article')
// Non-empty field
->condition('field_description', '', '<>')
// Multiple types
->condition('type', ['article', 'page'], 'IN')
// Field exists
->exists('field_reference')
// Field doesn't exist
->notExists('field_reference')
// Range of dates
->condition('created', $start_timestamp, '>=')
->condition('created', $end_timestamp, '<=')---
Sorting and Limiting
Sort results:
// Single sort
->sort('created', 'DESC')
->sort('title', 'ASC')
// Multiple sorts
->sort('field_featured', 'DESC')
->sort('created', 'DESC')Limit results:
// First 10 results
->range(0, 10)
// Pagination (offset, limit)
->range(20, 10) // Skip 20, get 10Count results:
// Get count instead of IDs
$count = $query->count()->execute();
// Check if results exist
$exists = $query->range(0, 1)->count()->execute() > 0;---
AND/OR Conditions
AND conditions (default):
// All conditions must match
$query = $storage->getQuery()
->accessCheck(FALSE)
->condition('type', 'article')
->condition('status', 1); // AND status = 1OR conditions:
$or = $query->orConditionGroup()
->condition('type', 'article')
->condition('type', 'page');
$query->condition($or);Complex AND/OR:
// (type = article OR type = page) AND status = 1
$or = $query->orConditionGroup()
->condition('type', 'article')
->condition('type', 'page');
$query->condition($or)
->condition('status', 1);Nested groups:
// (status = 1 AND promoted = 1) OR (sticky = 1)
$and = $query->andConditionGroup()
->condition('status', 1)
->condition('promote', 1);
$query->orConditionGroup()
->condition($and)
->condition('sticky', 1);---
Entity Reference Queries
Query by referenced entity ID:
// Nodes that reference term ID 5
->condition('field_tags', 5)
// Nodes that reference user ID 1
->condition('field_author', 1)Query by referenced entity field:
// Query through entity reference
->condition('field_author.entity:user.uid', $uid)
->condition('field_category.entity.name', 'Technology')---
Multi-Value Field Queries
Query specific delta (position in multi-value field):
// First value
->condition('field_items.0.value', 'test')
// Any delta
->condition('field_items.%delta.value', 'test')
// Multiple values
->condition('field_items.%delta.value', ['a', 'b'], 'IN')---
Date Queries
Query by timestamp:
// Created after date
$start_date = strtotime('2024-01-01');
->condition('created', $start_date, '>=')
// Created between dates
->condition('created', [$start, $end], 'BETWEEN')
// Changed in last 24 hours
$yesterday = \Drupal::time()->getRequestTime() - 86400;
->condition('changed', $yesterday, '>=')DrupalDateTime example:
use Drupal\Core\Datetime\DrupalDateTime;
$date = DrupalDateTime::createFromFormat('Y-m-d', '2024-01-01');
$timestamp = $date->getTimestamp();
->condition('field_date', $timestamp, '>=')---
User Queries
Query users:
$query = \Drupal::entityTypeManager()
->getStorage('user')
->getQuery()
->accessCheck(FALSE)
->condition('status', 1)
->condition('roles', 'authenticated', 'CONTAINS')
->sort('name', 'ASC');
$uids = $query->execute();---
Database Queries (Static)
Static query (direct SQL):
$database = \Drupal::database();
// Simple query
$query = $database->query("SELECT nid, title FROM {node_field_data} WHERE type = :type", [
':type' => 'article',
]);
$results = $query->fetchAll();
foreach ($results as $row) {
echo $row->title;
}Placeholders (required for security):
// CORRECT - Use placeholders
$query = $database->query("SELECT * FROM {node_field_data} WHERE nid = :nid", [
':nid' => $node_id,
]);
// WRONG - SQL injection risk
$query = $database->query("SELECT * FROM {node_field_data} WHERE nid = $node_id"); // ❌---
Database Queries (Dynamic)
Select query:
$database = \Drupal::database();
$query = $database->select('node_field_data', 'n')
->fields('n', ['nid', 'title', 'created'])
->condition('n.type', 'article')
->condition('n.status', 1)
->orderBy('n.created', 'DESC')
->range(0, 10);
$results = $query->execute()->fetchAll();Join tables:
$query = $database->select('node_field_data', 'n')
->fields('n', ['nid', 'title'])
->fields('u', ['name']);
// Inner join
$query->join('users_field_data', 'u', 'n.uid = u.uid');
// Left join
$query->leftJoin('node__field_tags', 't', 'n.nid = t.entity_id');
$results = $query->execute()->fetchAll();Aggregate queries:
$query = $database->select('node_field_data', 'n');
$query->addField('n', 'type');
$query->addExpression('COUNT(nid)', 'count');
$query->groupBy('n.type');
$results = $query->execute()->fetchAll();---
Insert/Update/Delete
Insert:
$database = \Drupal::database();
// Single insert
$database->insert('mytable')
->fields([
'name' => 'Example',
'value' => 123,
])
->execute();
// Multiple insert
$query = $database->insert('mytable')
->fields(['name', 'value']);
foreach ($items as $item) {
$query->values([$item['name'], $item['value']]);
}
$query->execute();Update:
$database->update('mytable')
->fields(['value' => 456])
->condition('name', 'Example')
->execute();Delete:
$database->delete('mytable')
->condition('id', 10, '<')
->execute();Merge (upsert):
$database->merge('mytable')
->keys(['id' => 123]) // Unique key to match
->fields([
'name' => 'Updated',
'value' => 789,
])
->execute();---
Batch Processing
Process large result sets:
$query = $storage->getQuery()
->accessCheck(FALSE)
->condition('type', 'article');
$nids = $query->execute();
// Process in batches of 100
$batches = array_chunk($nids, 100);
foreach ($batches as $batch) {
$nodes = $storage->loadMultiple($batch);
foreach ($nodes as $node) {
// Process node
$node->set('field_updated', time());
$node->save();
}
}---
Query Debugging
View generated SQL:
$sql = $query->__toString();
\Drupal::logger('my_module')->notice('Query: @sql', ['@sql' => $sql]);Enable query logging (settings.php):
$databases['default']['default']['pdo'][\PDO::ATTR_ERRMODE] = \PDO::ERRMODE_EXCEPTION;Log slow queries (MySQL):
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 2;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';---
---
Key Guidelines
✅ Use accessCheck() - Always required in Drupal 9.3+ ✅ Use entity queries - Primary method for database operations ✅ Use placeholders - For SQL injection prevention ✅ Use batch processing - For large datasets ✅ Use count() - To check existence efficiently ✅ Use range() - For pagination ✅ Use condition groups - For complex AND/OR logic ✅ Use __toString() - For debugging queries
❌ Don't skip accessCheck() - Required in Drupal 9.3+ ❌ Don't concatenate SQL - Use placeholders ❌ Don't load all results - Use range for large sets ❌ Don't use static queries - Unless necessary ❌ Don't forget {table} syntax - In static queries ❌ Don't query without conditions - Limit results ❌ Don't use get()->execute() - Use getQuery()->execute()
---
Full documentation: https://drupalatyourfingertips.com/queries
redirects
Source: Drupal at Your Fingertips - redirects Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/redirects
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
render
Source: Drupal at Your Fingertips - render Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/render
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
Routes & Controllers
Source: Drupal at Your Fingertips - routes Author: Selwyn Polit
Quick reference for Drupal routing and controllers with practical code examples.
---
Core Concept
Routes map URLs to controller methods. Defined in my_module.routing.yml, they specify path, controller, title, and access requirements.
Basic Route Definition
In my_module.routing.yml:
my_module.hello:
path: '/hello'
defaults:
_controller: '\Drupal\my_module\Controller\HelloController::hello'
_title: 'Hello Page'
requirements:
_permission: 'access content'Controller (src/Controller/HelloController.php):
namespace Drupal\my_module\Controller;
use Drupal\Core\Controller\ControllerBase;
class HelloController extends ControllerBase {
public function hello() {
return [
'#markup' => $this->t('Hello World!'),
];
}
}---
Route Parameters
Dynamic URL segments:
my_module.user_page:
path: '/user/{user}/profile'
defaults:
_controller: '\Drupal\my_module\Controller\UserController::viewProfile'
_title: 'User Profile'
requirements:
_permission: 'access user profiles'
user: \d+ # Numeric onlyController receives parameters:
public function viewProfile($user) {
$user_entity = User::load($user);
if (!$user_entity) {
throw new \Symfony\Component\HttpKernel\Exception\NotFoundHttpException();
}
return [
'#markup' => $this->t('Profile for @name', [
'@name' => $user_entity->getDisplayName(),
]),
];
}Auto-upcasting (load entity from parameter):
my_module.node_custom:
path: '/node/{node}/custom'
defaults:
_controller: '\Drupal\my_module\Controller\NodeController::customView'
requirements:
_permission: 'access content'
options:
parameters:
node:
type: entity:nodeuse Drupal\node\NodeInterface;
public function customView(NodeInterface $node) {
// $node is automatically loaded
return [
'#markup' => $this->t('Node title: @title', [
'@title' => $node->getTitle(),
]),
];
}---
Access Control
Permission-based:
requirements:
_permission: 'administer site configuration'Multiple permissions (OR logic with +):
requirements:
_permission: 'edit own content+administer content'Role-based:
requirements:
_role: 'administrator+editor'Custom access check:
requirements:
_custom_access: '\Drupal\my_module\Controller\MyController::checkAccess'public function checkAccess() {
$user = \Drupal::currentUser();
return AccessResult::allowedIf($user->id() > 1);
}---
Dynamic Page Titles
Title callback:
my_module.dynamic_title:
path: '/content/{node}'
defaults:
_controller: '\Drupal\my_module\Controller\ContentController::view'
_title_callback: '\Drupal\my_module\Controller\ContentController::getTitle'public function getTitle(NodeInterface $node) {
return $this->t('@title Details', ['@title' => $node->getTitle()]);
}---
Returning Different Response Types
Render array (most common):
public function buildPage() {
return [
'#theme' => 'my_template',
'#data' => $this->getData(),
];
}JSON response:
use Symfony\Component\HttpFoundation\JsonResponse;
public function apiEndpoint() {
$data = [
'status' => 'success',
'items' => $this->getItems(),
];
return new JsonResponse($data, 200, [
'Cache-Control' => 'no-cache, must-revalidate',
]);
}Redirect:
use Symfony\Component\HttpFoundation\RedirectResponse;
use Drupal\Core\Url;
public function redirectExample() {
$url = Url::fromRoute('my_module.other_page');
return new RedirectResponse($url->toString());
}File download:
use Symfony\Component\HttpFoundation\BinaryFileResponse;
public function downloadFile() {
$file_path = '/path/to/file.pdf';
return new BinaryFileResponse($file_path);
}---
ControllerBase Shortcuts
No DI required for these:
class MyController extends ControllerBase {
public function buildPage() {
// Entity storage
$storage = $this->entityTypeManager()->getStorage('node');
// Current user
$user = $this->currentUser();
// Configuration
$config = $this->config('system.site');
// Messenger
$this->messenger()->addStatus($this->t('Message'));
// Module handler
$this->moduleHandler()->moduleExists('views');
// Form builder
$form = $this->formBuilder()->getForm('Drupal\my_module\Form\MyForm');
return $form;
}
}---
Route Options
Disable caching:
options:
no_cache: TRUEAdmin route (uses admin theme):
options:
_admin_route: TRUEParameter constraints:
options:
parameters:
node:
type: entity:node
user:
type: entity:user---
Common Route Patterns
| Pattern | Example | Use Case |
|---|---|---|
| Simple page | /about | Static content |
| Entity view | /node/{node} | Entity display |
| Entity edit | /node/{node}/edit | Entity forms |
| User-specific | /user/{user}/messages | User-related pages |
| Admin config | /admin/config/my-module | Settings forms |
| API endpoint | /api/v1/content | JSON responses |
---
---
Debugging Routes
List all routes:
drush routeFind route by path:
drush route --path=/admin/configFind route by name:
drush route --name=my_module.helloGenerate controller:
drush generate controller---
Key Guidelines
✅ Use meaningful route names - my_module.action_description ✅ Validate parameters - Check input before using ✅ Use auto-upcasting - Let Drupal load entities ✅ Return correct response types - Render array for pages, JsonResponse for APIs ✅ Set appropriate access - Always require permissions ✅ Use title callbacks - For dynamic titles ✅ Follow URL patterns - Use hyphens, not underscores
❌ Don't use internal IDs in public URLs - Use UUIDs for APIs ❌ Don't skip access checks - Always set requirements ❌ Don't hardcode redirects - Use Url::fromRoute() ❌ Don't forget 404 responses - Throw NotFoundHttpException ❌ Don't use `_controller` for forms - Use _form instead
---
Full documentation: https://drupalatyourfingertips.com/routes
security
Source: Drupal at Your Fingertips - security Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/security
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
Services & Dependency Injection
Source: Drupal at Your Fingertips - services Author: Selwyn Polit
Quick reference for Drupal services and dependency injection patterns.
---
Core Concept
Services provide decoupled access to classes through the service container. Use dependency injection (DI) in classes for testable, pluggable code.
Two Access Methods
Static access (for procedural code like .module files):
$service = \Drupal::service('service.name');Dependency injection (preferred for classes):
// Services passed via constructor
public function __construct(AccountProxyInterface $account) {
$this->account = $account;
}---
Controller Dependency Injection
Standard pattern with four parts:
namespace Drupal\my_module\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Session\AccountProxyInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
class ExampleController extends ControllerBase {
// 1. Protected property
protected AccountProxyInterface $account;
// 2. create() method - gets services from container
public static function create(ContainerInterface $container) {
return new static(
$container->get('current_user')
);
}
// 3. Constructor - accepts and stores services
public function __construct(AccountProxyInterface $account) {
$this->account = $account;
}
// 4. Use the service
public function build() {
$username = $this->account->getAccountName();
return ['#markup' => "Hello $username"];
}
}---
Form Dependency Injection
Forms use FormBase and follow the same pattern:
use Drupal\Core\Form\FormBase;
use Drupal\Core\Config\ConfigFactoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
class MyForm extends FormBase {
protected ConfigFactoryInterface $configFactory;
public static function create(ContainerInterface $container) {
return new static(
$container->get('config.factory')
);
}
public function __construct(ConfigFactoryInterface $config_factory) {
$this->configFactory = $config_factory;
}
}---
Custom Service Definition
Define services in my_module.services.yml:
services:
my_module.my_service:
class: Drupal\my_module\MyService
arguments:
- '@entity_type.manager'
- '@current_user'
- '@config.factory'Then inject your custom service:
public static function create(ContainerInterface $container) {
return new static(
$container->get('my_module.my_service')
);
}---
Block/Plugin Dependency Injection
Blocks require ContainerFactoryPluginInterface and extra parameters:
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
class MyBlock extends BlockBase implements ContainerFactoryPluginInterface {
protected $currentUser;
public static function create(
ContainerInterface $container,
array $configuration,
$plugin_id,
$plugin_definition
) {
return new self(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('current_user')
);
}
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
AccountProxyInterface $current_user
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->currentUser = $current_user;
}
}---
Commonly Used Services
| Service | Purpose | Interface |
|---|---|---|
entity_type.manager | Load/query entities | EntityTypeManagerInterface |
current_user | Current user account | AccountProxyInterface |
config.factory | Configuration values | ConfigFactoryInterface |
messenger | User messages | MessengerInterface |
logger.factory | Watchdog logging | LoggerChannelFactoryInterface |
database | Database queries | Connection |
request_stack | HTTP request | RequestStack |
path.validator | Validate paths | PathValidatorInterface |
module_handler | Module info | ModuleHandlerInterface |
---
---
Key Benefits
✅ Testable - Mock services in PHPUnit tests ✅ Pluggable - Swap implementations via service container ✅ Explicit dependencies - Clear what each class needs ✅ No global state - Avoids static calls in class code
---
When to Use Static vs DI
Use `\Drupal::service()`:
.modulefiles (procedural code)- One-off procedural hooks
- Quick debugging
Use dependency injection:
- Controllers
- Forms
- Blocks
- Plugins
- Custom services
- Anything with automated tests
---
Full documentation: https://drupalatyourfingertips.com/services
setup_mac
Source: Drupal at Your Fingertips - setup_mac Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/setup_mac
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
state
Source: Drupal at Your Fingertips - state Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/state
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
taxonomy
Source: Drupal at Your Fingertips - taxonomy Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/taxonomy
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
Twig Templates
Source: Drupal at Your Fingertips - twig Author: Selwyn Polit
Quick reference for Twig templating in Drupal with practical code examples.
---
Core Concept
Twig is Drupal's template engine for rendering HTML. Templates use .html.twig extension and follow a suggestion hierarchy. Use content.field_name for rendered output (with labels/formatting) and node.field_name.value for raw values.
Twig Syntax
Three syntaxes:
{# This is a comment #}
{{ variable }} {# Output variable (auto-escaped) #}
{% if condition %} {# Logic statement #}
...
{% endif %}---
Template Naming & Suggestions
Naming pattern - Most specific to least:
{# Node templates #}
node--article--123.html.twig {# Specific node ID #}
node--article--full.html.twig {# Content type + view mode #}
node--article.html.twig {# Content type #}
node--full.html.twig {# View mode #}
node.html.twig {# Base template #}
{# Field templates #}
field--field-tags--article.html.twig {# Field + content type #}
field--field-tags.html.twig {# Field name #}
field--entity-reference.html.twig {# Field type #}
field.html.twig {# Base template #}
{# Views templates #}
views-view--my-view--page.html.twig {# View + display #}
views-view--my-view.html.twig {# View name #}
views-view.html.twig {# Base template #}Enable debug mode to see suggestions in HTML comments.
---
Variable Access Patterns
Two ways to access fields:
| Prefix | Purpose | Example |
|---|---|---|
content. | Rendered output with wrappers | {{ content.field_image }} |
node. | Raw field values | {{ node.field_image.0.target_id }} |
Content prefix (rendered with labels/formatting):
{{ content.field_image }}
{{ content.field_title }}
{{ content.body }}Node prefix (raw values):
{{ node.title.value }}
{{ node.field_tags.0.target_id }}
{{ node.field_date.value|date('Y-m-d') }}---
Common Field Types
Text fields:
{{ content.field_description }}
{{ node.field_description.value }}
{{ node.field_description.0.value }}Entity reference fields:
{# Get referenced entity ID #}
{{ node.field_author.0.target_id }}
{# Get referenced entity label #}
{{ node.field_author.entity.label }}
{# Get referenced entity field #}
{{ node.field_author.entity.field_bio.value }}Boolean fields:
{% if content.field_featured['#items'].0.value %}
<span class="featured">Featured</span>
{% endif %}Date fields:
{# Rendered date #}
{{ content.field_publish_date }}
{# Format raw date #}
{{ node.field_publish_date.value|date('F j, Y') }}
{{ node.field_publish_date.0.value|date('Y-m-d') }}
{# Node created/changed #}
{{ node.created.value|date('Y-m-d') }}
{{ node.changed.value|date('Y-m-d') }}Link fields:
{# Rendered link #}
{{ content.field_website }}
{# Link parts #}
<a href="{{ node.field_website.0.url }}">
{{ node.field_website.0.title }}
</a>File fields:
{# Rendered file #}
{{ content.field_document }}
{# File URL and name #}
{% for file in node.field_document %}
<a href="{{ file.entity.uri.value|file_url }}">
{{ file.entity.filename.value }}
</a>
{% endfor %}Image fields:
{# Rendered image #}
{{ content.field_image }}
{# Image URL #}
{{ node.field_image.entity.uri.value|file_url }}
{# Alt text #}
{{ node.field_image.alt }}---
Twig Filters
Essential Drupal filters:
| Filter | Purpose | Example |
|---|---|---|
| `\ | t` | Translate |
| `\ | raw` | Output unescaped HTML |
| `\ | striptags` | Remove HTML tags |
| `\ | render` | Render arrays |
| `\ | without('field')` | Exclude fields |
| `\ | file_url` | File URI to URL |
| `\ | date('format')` | Format dates |
| `\ | clean_class` | HTML class names |
| `\ | join('+')` | Join array |
| `\ | length` | Array/string length |
| `\ | upper, \ | lower` |
Common patterns:
{# Check if field has content #}
{% if content.body|render|striptags|trim is not empty %}
{{ content.body }}
{% endif %}
{# Remove specific tags #}
{{ content.field_text|striptags('<b>,<a>')|raw }}
{# Format class name #}
<div class="{{ node.bundle|clean_class }}">---
Twig Functions
Drupal functions:
{# Generate URLs #}
<a href="{{ url('entity.node.canonical', {node: 123}) }}">Node Link</a>
<a href="{{ path('entity.node.canonical', {node: 123}) }}">Relative Path</a>
{# Render views #}
{{ drupal_view('my_view', 'block_1') }}
{{ drupal_view('my_view', 'block_1', arg1, arg2) }}
{# Check view has results #}
{% if drupal_view_result('my_view', 'block_1')|length %}
{{ drupal_view('my_view', 'block_1') }}
{% endif %}
{# Render blocks #}
{{ drupal_block('system_branding_block') }}
{{ drupal_block('views_block:my_view-block_1') }}
{# Render specific field #}
{{ drupal_field('field_tags', 'node') }}
{# Debug output #}
{{ dump(variable) }}
{{ kint(variable) }}---
Control Structures
Conditionals:
{% if content.field_featured %}
<div class="featured">{{ content.field_featured }}</div>
{% elseif content.field_promoted %}
<div class="promoted">{{ content.field_promoted }}</div>
{% else %}
<div class="regular">Regular content</div>
{% endif %}Loops:
{% for item in items %}
{{ item }}
{% if loop.first %}First{% endif %}
{% if loop.last %}Last{% endif %}
{{ loop.index }} {# 1-indexed #}
{{ loop.index0 }} {# 0-indexed #}
{% endfor %}
{# Loop with separator #}
{% for tag in node.field_tags %}
{% if not loop.first %}, {% endif %}
{{ tag.entity.label }}
{% endfor %}Setting variables:
{% set classes = ['node', node.bundle, 'view-mode-' ~ view_mode] %}
<div{{ attributes.addClass(classes) }}>
{% set name = 'Hello !name'|t({'!name': user.name}) %}---
Attributes & Classes
Add classes:
{% set classes = ['article', 'featured'] %}
<div{{ attributes.addClass(classes) }}>
{# Conditional classes #}
{% set classes = [
'node',
node.bundle|clean_class,
node.isPromoted() ? 'promoted',
'view-mode-' ~ view_mode|clean_class,
] %}
<div{{ attributes.addClass(classes) }}>Attribute operations:
{{ attributes.setAttribute('data-id', node.id) }}
{{ attributes.removeClass('unwanted-class') }}
{{ attributes.removeAttribute('id') }}
{% if attributes.hasClass('check-this') %}
...
{% endif %}---
Including Templates
Include partial templates:
{# Include from theme #}
{% include '@mytheme/partials/header.html.twig' %}
{# Include with variables #}
{% include '@mytheme/partials/card.html.twig' with {
'title': node.title.value,
'image': node.field_image
} %}
{# Include only these variables #}
{% include '@mytheme/partials/item.html.twig' with {
'item': item
} only %}---
Preprocessing
Add variables in .theme file:
function mytheme_preprocess_node(&$variables) {
$node = $variables['node'];
// Add custom variable
$variables['author_name'] = $node->getOwner()->getDisplayName();
// Add formatted date
$variables['formatted_date'] = \Drupal::service('date.formatter')
->format($node->getCreatedTime(), 'custom', 'F j, Y');
}Use in template:
<div class="author">{{ author_name }}</div>
<div class="date">{{ formatted_date }}</div>---
Debugging
Enable debug mode (sites/development.services.yml):
parameters:
twig.config:
debug: true
auto_reload: true
cache: falseDebug functions:
{# Basic dump #}
{{ dump(variable) }}
{# Dump specific properties #}
{{ dump(node.title) }}
{{ dump(node.field_tags.0) }}
{# Kint (requires Devel module) #}
{{ kint(node) }}View template suggestions (in HTML source):
<!-- FILE NAME SUGGESTIONS:
* node--article--123.html.twig
* node--article--full.html.twig
x node--article.html.twig
* node--full.html.twig
* node.html.twig
-->---
Regions
Define in mytheme.info.yml:
regions:
header: Header
primary_menu: 'Primary Menu'
content: Content
sidebar_first: 'Left Sidebar'
footer: FooterUse in templates:
{% if page.header %}
<header>{{ page.header }}</header>
{% endif %}
{% if page.sidebar_first %}
<aside class="sidebar">{{ page.sidebar_first }}</aside>
{% endif %}---
---
Key Guidelines
✅ Use template suggestions - Most specific first ✅ Use content. for rendered output - Includes labels/wrappers ✅ Use node. for raw values - Direct field access ✅ Use filters for safety - |t, |striptags, |clean_class ✅ Enable debug mode - During development ✅ Use preprocessing - For complex logic ✅ Check field existence - Use {% if content.field_name %} ✅ Use Twig Tweak module - For drupal_view, drupal_block
❌ Don't use PHP in templates - Preprocess instead ❌ Don't forget |t filter - For translatable strings ❌ Don't use |raw carelessly - Security risk ❌ Don't access nodes directly - Use provided variables ❌ Don't cache templates in dev - Enable auto_reload ❌ Don't forget to clear cache - After adding templates ❌ Don't mix content. and node. - Choose one approach
---
Full documentation: https://drupalatyourfingertips.com/twig
upgrade
Source: Drupal at Your Fingertips - upgrade Author: Selwyn Polit
---
Full Documentation
View online: https://drupalatyourfingertips.com/upgrade
This chapter covers:
- Detailed explanations with code examples
- Best practices and common patterns
- Step-by-step implementation guides
- Troubleshooting and debugging tips
---
---
Last verified: 2025-10-31
Related skills
FAQ
Is Drupal At Your Fingertips safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.