From drupal-workflow
Provides patterns for Drupal 11 OOP hooks with #[Hook] attributes, form alters, entity hooks, and legacy bridges for Drupal 10/11. Use when implementing hooks, form alterations, or event subscribers.
npx claudepluginhub gkastanis/drupal-workflow --plugin drupal-workflowThis skill uses the workspace's default tool permissions.
Always prefer OOP hooks with `#[Hook]` attribute in Drupal 11+. Never put business logic directly in `.module` files — use services. Always check existing hook implementations before adding new ones to avoid conflicts.
Provides Drupal 10/11 service definitions, constructor injection, interfaces, and plugin factories. Use when creating services, registering dependencies, or service container patterns.
Mandates invoking relevant skills via tools before any response in coding sessions. Covers access, priorities, and adaptations for Claude Code, Copilot CLI, Gemini CLI.
Share bugs, ideas, or general feedback.
Always prefer OOP hooks with #[Hook] attribute in Drupal 11+. Never put business logic directly in .module files — use services. Always check existing hook implementations before adding new ones to avoid conflicts.
declare(strict_types=1);
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Hook implementations for my_module.
*/
final class MyModuleHooks {
use StringTranslationTrait;
#[Hook('form_alter')]
public function formAlter(array &$form, FormStateInterface $form_state, string $form_id): void {
if ($form_id === 'node_article_form') {
$form['title']['#description'] = $this->t('Enter a descriptive title.');
}
}
#[Hook('entity_presave')]
public function entityPresave(EntityInterface $entity): void {
if ($entity->getEntityTypeId() === 'node' && $entity->bundle() === 'article') {
// Set default values before saving.
}
}
}
// my_module.module
use Drupal\Core\Hook\Attribute\LegacyHook;
/**
* Implements hook_form_alter().
*/
#[LegacyHook]
function my_module_form_alter(array &$form, FormStateInterface $form_state, string $form_id): void {
\Drupal::classResolver(MyModuleHooks::class)->formAlter($form, $form_state, $form_id);
}
/**
* Implements hook_form_FORM_ID_alter() for node_article_form.
*/
function my_module_form_node_article_form_alter(array &$form, FormStateInterface $form_state): void {
// Add validation.
$form['#validate'][] = '_my_module_article_validate';
// Modify field widgets.
$form['field_category']['widget']['#required'] = TRUE;
}
/**
* Implements hook_entity_presave().
*/
function my_module_entity_presave(EntityInterface $entity): void {
if ($entity instanceof NodeInterface && $entity->bundle() === 'event') {
// Set computed field values.
}
}
/**
* Implements hook_entity_insert().
*/
function my_module_entity_insert(EntityInterface $entity): void {
if ($entity instanceof NodeInterface) {
// Post-creation actions (notifications, indexing).
}
}
/**
* Implements hook_entity_access().
*/
function my_module_entity_access(EntityInterface $entity, string $operation, AccountInterface $account): AccessResultInterface {
if ($entity->getEntityTypeId() === 'node' && $entity->bundle() === 'private_content') {
return AccessResult::forbiddenIf(
$operation === 'view' && !$account->hasPermission('view private content')
)->cachePerPermissions();
}
return AccessResult::neutral();
}
/**
* Implements hook_theme().
*/
function my_module_theme(): array {
return [
'my_module_component' => [
'variables' => [
'title' => NULL,
'items' => [],
'attributes' => NULL,
],
],
];
}
/**
* Implements hook_preprocess_HOOK() for node templates.
*/
function my_module_preprocess_node(array &$variables): void {
$node = $variables['node'];
if ($node->bundle() === 'article') {
$variables['reading_time'] = _my_module_calculate_reading_time($node);
}
}
declare(strict_types=1);
namespace Drupal\my_module\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class MyEventSubscriber implements EventSubscriberInterface {
public static function getSubscribedEvents(): array {
return [
KernelEvents::REQUEST => ['onRequest', 100],
];
}
public function onRequest(RequestEvent $event): void {
// Handle incoming request.
}
}
These hooks live in .install files, not .module files. They handle module lifecycle and database schema migrations.
/**
* Implements hook_install().
*/
function my_module_install(): void {
// Runs once when module is first enabled.
// Set initial state, create default content, grant permissions.
}
/**
* Implements hook_uninstall().
*/
function my_module_uninstall(): void {
// Clean up state, delete variables, remove custom tables.
\Drupal::state()->delete('my_module.last_run');
}
Number update hooks sequentially. Each runs exactly once per environment. Keep them idempotent — safe to run on databases in any state.
/**
* Add the 'priority' base field to my_entity.
*/
function my_module_update_10001(): void {
$field = BaseFieldDefinition::create('integer')
->setLabel(t('Priority'))
->setDefaultValue(0);
\Drupal::entityDefinitionUpdateManager()
->installFieldStorageDefinition('priority', 'my_entity', 'my_module', $field);
}
Use hook_post_update_NAME() for data changes that require the entity system to be fully updated. These run after all hook_update_N() hooks.
/**
* Populate priority field with default values for existing entities.
*/
function my_module_post_update_set_default_priority(array &$sandbox): void {
// Use batch processing for large datasets.
if (!isset($sandbox['total'])) {
$sandbox['ids'] = \Drupal::entityQuery('my_entity')->accessCheck(FALSE)->execute();
$sandbox['total'] = count($sandbox['ids']);
$sandbox['current'] = 0;
}
$batch = array_splice($sandbox['ids'], 0, 50);
$storage = \Drupal::entityTypeManager()->getStorage('my_entity');
foreach ($storage->loadMultiple($batch) as $entity) {
$entity->set('priority', 0)->save();
$sandbox['current']++;
}
$sandbox['#finished'] = $sandbox['total'] ? $sandbox['current'] / $sandbox['total'] : 1;
}
#[Hook] attribute (Drupal 11+).#[LegacyHook] bridge for Drupal 10 compatibility..module file thin - hooks call services.hook_form_FORM_ID_alter) over generic.