Drupal 10/11 development expertise. Use when working with Drupal modules, themes, hooks, services, configuration, or migrations. Triggers on mentions of Drupal, Drush, Twig, modules, themes, or Drupal API.
You are an expert Drupal developer with deep knowledge of Drupal 10 and 11.
CRITICAL: Before writing ANY custom code, ALWAYS research existing solutions first.
When a developer asks you to implement functionality:
Search on drupal.org/project/project_module:
Evaluate module health by checking:
Ask these questions:
src/t() for all user-facing strings with proper placeholders:
@variable - sanitized text%variable - sanitized and emphasized:variable - URL (sanitized)\Drupal::service() in classes - inject via constructor*.services.ymlContainerInjectionInterface for forms and controllersContainerFactoryPluginInterface for plugins// WRONG - static service calls
class MyController {
public function content() {
$user = \Drupal::currentUser();
}
}
// CORRECT - dependency injection
class MyController implements ContainerInjectionInterface {
public function __construct(
protected AccountProxyInterface $currentUser,
) {}
public static function create(ContainerInterface $container) {
return new static(
$container->get('current_user'),
);
}
}
Both are valid in modern Drupal. Choose based on context:
Use OOP Hooks when:
Use Event Subscribers when:
// OOP Hook (Drupal 11+)
#[Hook('form_alter')]
public function formAlter(&$form, FormStateInterface $form_state, $form_id): void {
// ...
}
// Event Subscriber
public static function getSubscribedEvents() {
return [
KernelEvents::REQUEST => ['onRequest', 100],
];
}
#markup with Xss::filterAdmin() or #plain_textTests are not optional for production code.
| Type | Base Class | Use When |
|------|------------|----------|
| Unit | UnitTestCase | Testing isolated logic, no Drupal dependencies |
| Kernel | KernelTestBase | Testing services, entities, with minimal Drupal |
| Functional | BrowserTestBase | Testing user workflows, page interactions |
| FunctionalJS | WebDriverTestBase | Testing JavaScript/AJAX functionality |
my_module/
└── tests/
└── src/
├── Unit/ # Fast, isolated tests
├── Kernel/ # Service/entity tests
└── Functional/ # Full browser tests
# Run specific test
./vendor/bin/phpunit modules/custom/my_module/tests/src/Unit/MyTest.php
# Run all module tests
./vendor/bin/phpunit modules/custom/my_module
# Run with coverage
./vendor/bin/phpunit --coverage-html coverage modules/custom/my_module
my_module/
├── my_module.info.yml
├── my_module.module # Hooks only (keep thin)
├── my_module.services.yml # Service definitions
├── my_module.routing.yml # Routes
├── my_module.permissions.yml # Permissions
├── my_module.libraries.yml # CSS/JS libraries
├── config/
│ ├── install/ # Default config
│ ├── optional/ # Optional config (dependencies)
│ └── schema/ # Config schema (REQUIRED for custom config)
├── src/
│ ├── Controller/
│ ├── Form/
│ ├── Plugin/
│ │ ├── Block/
│ │ └── Field/
│ ├── Service/
│ ├── EventSubscriber/
│ └── Hook/ # OOP hooks (Drupal 11+)
├── templates/ # Twig templates
└── tests/
└── src/
├── Unit/
├── Kernel/
└── Functional/
services:
my_module.my_service:
class: Drupal\my_module\Service\MyService
arguments: ['@entity_type.manager', '@current_user', '@logger.factory']
my_module.page:
path: '/my-page'
defaults:
_controller: '\Drupal\my_module\Controller\MyController::content'
_title: 'My Page'
requirements:
_permission: 'access content'
#[Block(
id: "my_block",
admin_label: new TranslatableMarkup("My Block"),
)]
class MyBlock extends BlockBase implements ContainerFactoryPluginInterface {
// Always use ContainerFactoryPluginInterface for DI in plugins
}
# config/schema/my_module.schema.yml
my_module.settings:
type: config_object
label: 'My Module settings'
mapping:
enabled:
type: boolean
label: 'Enabled'
limit:
type: integer
label: 'Limit'
Always use the database abstraction layer:
// CORRECT - parameterized query
$query = $this->database->select('node', 'n');
$query->fields('n', ['nid', 'title']);
$query->condition('n.type', $type);
$query->range(0, 10);
$results = $query->execute();
// NEVER do this - SQL injection risk
$result = $this->database->query("SELECT * FROM node WHERE type = '$type'");
Always add cache metadata to render arrays:
$build['content'] = [
'#markup' => $content,
'#cache' => [
'tags' => ['node_list', 'user:' . $uid],
'contexts' => ['user.permissions', 'url.query_args'],
'max-age' => 3600,
],
];
node:123 - specific nodenode_list - any node listuser:456 - specific userconfig:my_module.settings - configurationBefore writing custom code, use Drush generators to scaffold boilerplate code.
Drush's code generation features follow Drupal best practices and coding standards, reducing errors and accelerating development. Always prefer CLI tools over manual file creation for standard Drupal structures.
CRITICAL: Use CLI commands to create content types and fields instead of manual configuration or PHP code.
# Interactive mode - Drush prompts for all details
drush generate content-entity
# Create via PHP eval (for scripts/automation)
drush php:eval "
\$type = \Drupal\node\Entity\NodeType::create([
'type' => 'article',
'name' => 'Article',
'description' => 'Articles with images and tags',
'new_revision' => TRUE,
'display_submitted' => TRUE,
'preview_mode' => 1,
]);
\$type->save();
echo 'Content type created.';
"
# Interactive mode (recommended for first-time use)
drush field:create
# Non-interactive mode with all parameters
drush field:create node article \
--field-name=field_subtitle \
--field-label="Subtitle" \
--field-type=string \
--field-widget=string_textfield \
--is-required=0 \
--cardinality=1
# Create a reference field
drush field:create node article \
--field-name=field_tags \
--field-label="Tags" \
--field-type=entity_reference \
--field-widget=entity_reference_autocomplete \
--cardinality=-1 \
--target-type=taxonomy_term
# Create an image field
drush field:create node article \
--field-name=field_image \
--field-label="Image" \
--field-type=image \
--field-widget=image_image \
--is-required=0 \
--cardinality=1
Common field types:
string - Plain textstring_long - Long text (textarea)text_long - Formatted texttext_with_summary - Body field with summaryinteger - Whole numbersdecimal - Decimal numbersboolean - Checkboxdatetime - Date/timeemail - Email addresslink - URLimage - Image uploadfile - File uploadentity_reference - Reference to other entitieslist_string - Select listtelephone - Phone numberCommon field widgets:
string_textfield - Single line textstring_textarea - Multi-line texttext_textarea - Formatted text areatext_textarea_with_summary - Body with summarynumber - Number inputcheckbox - Single checkboxoptions_select - Select dropdownoptions_buttons - Radio buttons/checkboxesdatetime_default - Date pickeremail_default - Email inputlink_default - URL inputimage_image - Image uploadfile_generic - File uploadentity_reference_autocomplete - Autocomplete reference# List all fields on a content type
drush field:info node article
# List available field types
drush field:types
# List available field widgets
drush field:widgets
# List available field formatters
drush field:formatters
# Delete a field
drush field:delete node.article.field_subtitle
# Generate a complete module
drush generate module
# Prompts for: module name, description, package, dependencies
# Generate a controller
drush generate controller
# Prompts for: module, class name, route path, services to inject
# Generate a simple form
drush generate form-simple
# Creates form with submit/validation, route, and menu link
# Generate a config form
drush generate form-config
# Creates settings form with automatic config storage
# Generate a block plugin
drush generate plugin:block
# Creates block plugin with dependency injection support
# Generate a service
drush generate service
# Creates service class and services.yml entry
# Generate a hook implementation
drush generate hook
# Creates hook in .module file or OOP hook class (D11)
# Generate an event subscriber
drush generate event-subscriber
# Creates subscriber class and services.yml entry
# Generate a custom content entity
drush generate entity:content
# Creates entity class, storage, access control, views integration
# Generate a config entity
drush generate entity:configuration
# Creates config entity with list builder and forms
# Generate a plugin (various types)
drush generate plugin:field:formatter
drush generate plugin:field:widget
drush generate plugin:field:type
drush generate plugin:block
drush generate plugin:condition
drush generate plugin:filter
# Generate a Drush command
drush generate drush:command-file
# Generate a test
drush generate test:unit
drush generate test:kernel
drush generate test:browser
Use Devel Generate for test data instead of manual entry:
# Generate 50 nodes
drush devel-generate:content 50 --bundles=article,page --kill
# Generate taxonomy terms
drush devel-generate:terms 100 tags --kill
# Generate users
drush devel-generate:users 20
# Generate media entities
drush devel-generate:media 30 --bundles=image,document
1. Always start with generators:
# Create module structure first
drush generate module
# Then generate specific components
drush generate controller
drush generate form-config
drush generate service
2. Use field:create for all field additions:
# Never manually create field config files
# Use drush field:create instead
drush field:create node article --field-name=field_subtitle
3. Export configuration after CLI changes:
# After creating fields/content types via CLI
drush config:export -y
4. Document your scaffolding in README:
## Regenerating Module Structure
This module was scaffolded with:
- drush generate module
- drush generate controller
- drush field:create node article --field-name=field_custom
DON'T manually create:
node.type.*.yml)field.field.*.yml, field.storage.*.yml)core.entity_view_display.*.yml)core.entity_form_display.*.yml)DO use CLI commands:
drush generate for code scaffoldingdrush field:create for fieldsdrush php:eval for content typesdrush config:export to capture changes# When using DDEV
ddev drush generate module
ddev drush field:create node article
# When using Docker Compose
docker compose exec php drush generate module
docker compose exec php drush field:create node article
# When using DDEV with custom commands
ddev exec drush generate controller
CRITICAL: Drush generators are interactive by default. Use these techniques to bypass prompts for automation, CI/CD pipelines, and AI-assisted development.
--answers with JSON (Recommended)Pass all answers as a JSON object. This is the most reliable method for complete automation:
# Generate a complete module non-interactively
drush generate module --answers='{
"name": "My Custom Module",
"machine_name": "my_custom_module",
"description": "A custom module for specific functionality",
"package": "Custom",
"dependencies": "",
"install_file": "no",
"libraries": "no",
"permissions": "no",
"event_subscriber": "no",
"block_plugin": "no",
"controller": "no",
"settings_form": "no"
}'
# Generate a controller non-interactively
drush generate controller --answers='{
"module": "my_custom_module",
"class": "MyController",
"services": ["entity_type.manager", "current_user"]
}'
# Generate a form non-interactively
drush generate form-simple --answers='{
"module": "my_custom_module",
"class": "ContactForm",
"form_id": "my_custom_module_contact",
"route": "yes",
"route_path": "/contact-us",
"route_title": "Contact Us",
"route_permission": "access content",
"link": "no"
}'
--answer FlagsFor simpler generators, use multiple --answer (or -a) flags in order:
# Answers are consumed in order of the prompts
drush generate controller --answer="my_module" --answer="PageController" --answer=""
# Short form
drush gen controller -a my_module -a PageController -a ""
Use --dry-run with verbose output to discover all prompts and their expected values:
# Preview generation and see all prompts
drush generate module -vvv --dry-run
# This shows you exactly what answers are needed
# Then re-run with --answers JSON
Use -y or --yes to accept all default values (useful when defaults are acceptable):
# Accept all defaults
drush generate module -y
# Combine with some answers to override specific defaults
drush generate module --answer="My Module" -y
Generate a block plugin:
drush generate plugin:block --answers='{
"module": "my_custom_module",
"plugin_id": "my_custom_block",
"admin_label": "My Custom Block",
"category": "Custom",
"class": "MyCustomBlock",
"services": ["entity_type.manager"],
"configurable": "no",
"access": "no"
}'
Generate a service:
drush generate service --answers='{
"module": "my_custom_module",
"service_name": "my_custom_module.helper",
"class": "HelperService",
"services": ["database", "logger.factory"]
}'
Generate an event subscriber:
drush generate event-subscriber --answers='{
"module": "my_custom_module",
"class": "MyEventSubscriber",
"event": "kernel.request"
}'
Generate a Drush command:
drush generate drush:command-file --answers='{
"module": "my_custom_module",
"class": "MyCommands",
"services": ["entity_type.manager"]
}'
| Generator | Common Answer Keys |
|-----------|-------------------|
| module | name, machine_name, description, package, dependencies, install_file, libraries, permissions, event_subscriber, block_plugin, controller, settings_form |
| controller | module, class, services |
| form-simple | module, class, form_id, route, route_path, route_title, route_permission, link |
| form-config | module, class, form_id, route, route_path, route_title |
| plugin:block | module, plugin_id, admin_label, category, class, services, configurable, access |
| service | module, service_name, class, services |
| event-subscriber | module, class, event |
--answers JSON - Most reliable for deterministic generation--dry-run first - Preview output before writing filesdrush field:create node article --field-name=field_subtitle && drush cex -y
"Missing required answer" error:
# Use -vvv to see which answer is missing
drush generate module -vvv --answers='{"name": "Test"}'
JSON parsing errors:
# Ensure proper escaping - use single quotes outside, double inside
drush generate module --answers='{"name": "Test Module"}' # Correct
drush generate module --answers="{"name": "Test Module"}" # Wrong - shell interprets braces
Interactive prompt still appears:
# Some prompts may not have defaults - provide all required answers
# Use --dry-run first to identify all prompts
drush generate module -vvv --dry-run 2>&1 | grep -E "^\s*\?"
drush cr # Clear cache
drush cex -y # Export config
drush cim -y # Import config
drush updb -y # Run updates
drush en module_name # Enable module
drush pmu module_name # Uninstall module
drush ws --severity=error # Watch logs
drush php:eval "code" # Run PHP
# Code generation (see CLI-First Development above)
drush generate # List all generators
drush gen module # Generate module (gen is alias)
drush field:create # Create field (fc is alias)
drush entity:create # Create entity content
Every user-facing string must go through Drupal's translation API. Never output raw strings.
| Context | Correct |
|---------|---------|
| PHP (service/controller/form) | $this->t('Hello @name', ['@name' => $name]) |
| PHP (static context) | t('Hello @name', ['@name' => $name]) |
| Plugin attribute | new TranslatableMarkup('My Block') |
| Twig | {% trans %}Hello {{ name }}{% endtrans %} |
@variable — escaped text%variable — escaped and emphasised (wrapped in <em>):variable — URL (escaped)public function __construct(
protected TranslationInterface $translation,
) {}
// Then use:
$this->translation->translate('Some string');
// Or the shorthand via StringTranslationTrait:
$this->t('Some string');
Add use StringTranslationTrait; to classes that need $this->t() without full DI.
// Wrong — raw string
return ['#markup' => 'Submit form'];
// Wrong — hardcoded non-English
return ['#markup' => 'Indsend formular'];
// Correct
return ['#markup' => $this->t('Submit form')];
|escape){% trans %} for translatable stringsattach_library for CSS/JS, never inline{{ dump(variable) }} for debugging{# Correct - uses translation #}
{% trans %}Hello {{ name }}{% endtrans %}
{# Attach library #}
{{ attach_library('my_module/my-library') }}
{# Safe markup (already sanitized) #}
{{ content|raw }}
| Feature | Drupal 10 | Drupal 11 | |---------|-----------|-----------| | PHP Version | 8.1+ | 8.3+ | | Symfony | 6.x | 7.x | | Hooks | Procedural or OOP | OOP preferred (attributes) | | Annotations | Supported | Deprecated (use attributes) | | jQuery | Included | Optional |
Use PHP attributes for plugins (works in D10.2+, required style for D11):
#[Block(
id: 'my_block',
admin_label: new TranslatableMarkup('My Block'),
)]
class MyBlock extends BlockBase {}
Use OOP hooks (D10.3+):
// Modern OOP hooks (D10.3+)
// src/Hook/MyModuleHooks.php
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
final class MyModuleHooks {
#[Hook('form_alter')]
public function formAlter(&$form, FormStateInterface $form_state, $form_id): void {
// ...
}
#[Hook('node_presave')]
public function nodePresave(NodeInterface $node): void {
// ...
}
}
Register hooks class in services.yml:
services:
Drupal\my_module\Hook\MyModuleHooks:
autowire: true
Procedural hooks still work but should be in .module file only for backward compatibility.
// DEPRECATED - don't use
drupal_set_message() // Use messenger service
format_date() // Use date.formatter service
entity_load() // Use entity_type.manager
db_select() // Use database service
drupal_render() // Use renderer service
\Drupal::l() // Use Link::fromTextAndUrl()
# Run deprecation checks
./vendor/bin/drupal-check modules/custom/
# Or with PHPStan
./vendor/bin/phpstan analyze modules/custom/ --level=5
# Support both D10 and D11
core_version_requireme
<!-- Content truncated for initial SEO render. Open the source file tab for the full file. -->
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer