---
name: drupal-module-scaffold
description: 'Scaffold and build custom Drupal 11 modules from scratch. Use when: creating a new custom module, generating module file structure, writing info.yml permissions.yml links.menu.yml links.task.yml links.action.yml routing.yml services.yml libraries.yml install files, implementing hook_schema hook_install hook_uninstall hook_update_N, creating Block plugins Field formatter plugins QueueWorker plugins, writing Drush commands, creating custom entities, registering tokens, wiring theme hooks, building admin config forms, custom logger channels.'
argument-hint: 'Module machine name and brief description of what it should do'
---

# Drupal 11 — Custom Module Scaffolding

> **Also load:** [drupal-11-coding-standards](../drupal-11-coding-standards/SKILL.md) for PHPCS rules and DI patterns that apply to all code written within the module.

## When to Use
- Building a new custom module from nothing
- Adding a new file type (plugin, form, controller, service) to an existing module
- Wiring up permissions, menu links, admin pages, or routes
- Implementing database schema, install/update hooks
- Creating Block, QueueWorker, or Mail plugins
- Adding Drush commands or custom logger channels

---

## Module Naming Convention

All custom modules on this project use the **site machine name** as their prefix:
- Site: `naomi` → modules: `naomi_core`, `naomi_events`, `naomi_api`
- Functions: `naomi_core_node_presave()`, `naomi_core_theme()`
- Services: `naomi_core.helper`
- Config: `naomi_core.settings`

---

## Minimum File Set

```
web/modules/custom/mymodule/
├── mymodule.info.yml           # Required
├── mymodule.module             # Optional but common — hooks only
├── mymodule.install            # Optional — schema + update hooks
├── mymodule.routing.yml        # If defining routes
├── mymodule.permissions.yml    # If defining permissions
├── mymodule.services.yml       # If defining services/event subscribers
├── mymodule.libraries.yml      # If attaching CSS/JS assets
├── mymodule.links.menu.yml     # If adding admin menu links
├── mymodule.links.task.yml     # If adding local task tabs
├── mymodule.links.action.yml   # If adding action buttons
├── config/
│   ├── install/                # Config deployed on module install
│   │   └── mymodule.settings.yml
│   └── schema/
│       └── mymodule.schema.yml # Required for any config
└── src/
    ├── Controller/
    ├── Form/
    ├── Plugin/
    │   ├── Block/
    │   └── QueueWorker/
    └── EventSubscriber/
```

---

## mymodule.info.yml

```yaml
name: My Module
type: module
description: 'Brief description of what this module does.'
core_version_requirement: ^11
package: Custom

# Optional: link to admin settings form
configure: mymodule.settings_form

# Optional: dependencies
dependencies:
  - drupal:node
  - drupal:views
```

**Rules:**
- `core_version_requirement: ^11` for Drupal 11 only; use `^10 || ^11` for backward compat
- `package: Custom` for all site-specific modules
- Never add `core: 8.x` — that is deprecated
- The `configure` key must match a route name that renders an admin form

---

## mymodule.module

Always include the `@file` doc block. Only put procedural hooks here — no class logic.

```php
<?php

/**
 * @file
 * Primary module hooks for the My Module module.
 */

use Drupal\Core\Routing\RouteMatchInterface;

/**
 * Implements hook_help().
 */
function mymodule_help(string $route_name, RouteMatchInterface $route_match): string {
  if ($route_name === 'help.page.mymodule') {
    return '<p>' . t('Provides custom functionality for the site.') . '</p>';
  }
  return '';
}
```

---

## mymodule.routing.yml

```yaml
# Standard page route
mymodule.example_page:
  path: '/example'
  defaults:
    _controller: '\Drupal\mymodule\Controller\ExampleController::build'
    _title: 'Example Page'
  requirements:
    _permission: 'access content'

# Admin settings form route
mymodule.settings_form:
  path: '/admin/config/system/mymodule'
  defaults:
    _form: '\Drupal\mymodule\Form\SettingsForm'
    _title: 'My Module Settings'
  requirements:
    _permission: 'administer site configuration'
  options:
    _admin_route: TRUE

# Route with entity parameter (auto-upcasted)
mymodule.node_detail:
  path: '/node/{node}/detail'
  defaults:
    _controller: '\Drupal\mymodule\Controller\NodeDetailController::build'
    _title: 'Node Detail'
  requirements:
    _permission: 'access content'
    node: \d+
  options:
    parameters:
      node:
        type: entity:node
```

---

## mymodule.permissions.yml

```yaml
administer mymodule:
  title: 'Administer My Module'
  description: 'Perform administration tasks for My Module.'
  restrict access: true

view mymodule content:
  title: 'View My Module content'
  description: 'View content managed by My Module.'
```

---

## mymodule.links.menu.yml

```yaml
# Adds a link under Configuration > System
mymodule.settings_form:
  title: 'My Module Settings'
  route_name: mymodule.settings_form
  description: 'Configure My Module settings.'
  parent: system.admin_config_system
```

Common `parent` values:
- `system.admin` — Administration
- `system.admin_config` — Configuration
- `system.admin_config_system` — Configuration > System
- `system.admin_config_services` — Configuration > Web Services
- `system.admin_content` — Content
- `system.admin_people` — People

---

## mymodule.links.task.yml

Local tasks appear as tabs on an admin page.

```yaml
mymodule.settings_form:
  route_name: mymodule.settings_form
  title: 'Settings'
  base_route: mymodule.settings_form

mymodule.advanced_form:
  route_name: mymodule.advanced_form
  title: 'Advanced'
  base_route: mymodule.settings_form
```

---

## mymodule.links.action.yml

Action links appear as buttons (e.g. "+ Add item").

```yaml
mymodule.add_item:
  route_name: mymodule.add_item_form
  title: 'Add item'
  appears_on:
    - mymodule.items_list
```

---

## mymodule.services.yml

```yaml
services:
  # Standard service
  mymodule.helper:
    class: Drupal\mymodule\MyModuleHelper
    arguments:
      - '@entity_type.manager'
      - '@config.factory'
      - '@current_user'

  # Custom logger channel
  logger.channel.mymodule:
    class: Drupal\Core\Logger\LoggerChannel
    factory: logger.factory:get
    arguments: ['mymodule']

  # Event subscriber
  mymodule.config_subscriber:
    class: Drupal\mymodule\EventSubscriber\ConfigSubscriber
    tags:
      - { name: event_subscriber }
```

Inject the custom logger via `'@logger.channel.mymodule'`.

---

## mymodule.libraries.yml

```yaml
mymodule.admin:
  version: 1.x
  css:
    theme:
      css/mymodule-admin.css: {}
  js:
    js/mymodule-admin.js: {}
  dependencies:
    - core/jquery
    - core/drupal

mymodule.frontend:
  version: 1.x
  css:
    theme:
      css/mymodule.css: {}
```

Attach in a render array:
```php
$build['#attached']['library'][] = 'mymodule/mymodule.admin';
```

Attach from a `hook_preprocess_HOOK()`:
```php
function mymodule_preprocess_node(array &$variables): void {
  $variables['#attached']['library'][] = 'mymodule/mymodule.frontend';
}
```

---

## mymodule.install — Schema & Updates

```php
<?php

/**
 * @file
 * Install, update, and uninstall functions for My Module.
 */

/**
 * Implements hook_schema().
 */
function mymodule_schema(): array {
  $schema['mymodule_items'] = [
    'description' => 'Stores custom items for My Module.',
    'fields' => [
      'id' => [
        'type' => 'serial',
        'unsigned' => TRUE,
        'not null' => TRUE,
        'description' => 'Primary key.',
      ],
      'label' => [
        'type' => 'varchar',
        'length' => 255,
        'not null' => TRUE,
        'default' => '',
        'description' => 'The item label.',
      ],
      'data' => [
        'type' => 'blob',
        'not null' => FALSE,
        'description' => 'Serialised data.',
      ],
      'created' => [
        'type' => 'int',
        'not null' => TRUE,
        'default' => 0,
        'description' => 'Unix timestamp of creation.',
      ],
    ],
    'primary key' => ['id'],
    'indexes' => [
      'label' => ['label'],
    ],
  ];
  return $schema;
}

/**
 * Implements hook_install().
 */
function mymodule_install(): void {
  \Drupal::messenger()->addStatus(t('My Module has been installed.'));
}

/**
 * Implements hook_uninstall().
 */
function mymodule_uninstall(): void {
  // Clean up any variables or tables if schema not removed automatically.
}

/**
 * Add the `status` column to mymodule_items.
 */
function mymodule_update_11001(): void {
  $spec = [
    'type' => 'int',
    'not null' => TRUE,
    'default' => 1,
    'description' => 'Whether the item is active.',
  ];
  \Drupal::database()
    ->schema()
    ->addField('mymodule_items', 'status', $spec);
}
```

**Update hook naming:** `mymodule_update_11001()` — prefix `11` for Drupal 11 updates.

---

## config/install/mymodule.settings.yml

```yaml
# Deployed when the module is installed.
# Keys must match the schema definition.
enabled: true
api_endpoint: 'https://api.example.com'
items_per_page: 10
```

## config/schema/mymodule.schema.yml

```yaml
mymodule.settings:
  type: config_object
  label: 'My Module settings'
  mapping:
    enabled:
      type: boolean
      label: 'Module enabled'
    api_endpoint:
      type: string
      label: 'API endpoint URL'
    items_per_page:
      type: integer
      label: 'Items per page'
```

---

## src/Controller/ExampleController.php

```php
<?php

namespace Drupal\mymodule\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Returns responses for My Module routes.
 */
class ExampleController extends ControllerBase {

  /**
   * Constructs an ExampleController.
   */
  public function __construct(
    protected readonly EntityTypeManagerInterface $entityTypeManager,
  ) {}

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container): static {
    return new static(
      $container->get('entity_type.manager'),
    );
  }

  /**
   * Builds the response for the example page.
   *
   * @return array
   *   A render array.
   */
  public function build(): array {
    return [
      '#theme' => 'mymodule_example',
      '#cache' => [
        'tags' => ['node_list'],
      ],
    ];
  }

}
```

---

## src/Form/SettingsForm.php

```php
<?php

namespace Drupal\mymodule\Form;

use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;

/**
 * Configure My Module settings.
 */
class SettingsForm extends ConfigFormBase {

  /**
   * {@inheritdoc}
   */
  public function getFormId(): string {
    return 'mymodule_settings_form';
  }

  /**
   * {@inheritdoc}
   */
  protected function getEditableConfigNames(): array {
    return ['mymodule.settings'];
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state): array {
    $config = $this->config('mymodule.settings');

    $form['enabled'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Enable My Module'),
      '#default_value' => $config->get('enabled'),
    ];

    $form['api_endpoint'] = [
      '#type' => 'url',
      '#title' => $this->t('API endpoint'),
      '#default_value' => $config->get('api_endpoint'),
      '#required' => TRUE,
    ];

    $form['items_per_page'] = [
      '#type' => 'number',
      '#title' => $this->t('Items per page'),
      '#default_value' => $config->get('items_per_page'),
      '#min' => 1,
      '#max' => 100,
    ];

    return parent::buildForm($form, $form_state);
  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state): void {
    $this->config('mymodule.settings')
      ->set('enabled', $form_state->getValue('enabled'))
      ->set('api_endpoint', $form_state->getValue('api_endpoint'))
      ->set('items_per_page', $form_state->getValue('items_per_page'))
      ->save();

    parent::submitForm($form, $form_state);
  }

}
```

---

## src/Plugin/Block/ExampleBlock.php

```php
<?php

namespace Drupal\mymodule\Plugin\Block;

use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;

/**
 * Provides the Example block.
 */
#[Block(
  id: 'mymodule_example_block',
  admin_label: new TranslatableMarkup('My Module: Example Block'),
  category: new TranslatableMarkup('Custom'),
)]
class ExampleBlock extends BlockBase {

  /**
   * {@inheritdoc}
   */
  public function build(): array {
    return [
      '#markup' => $this->t('Example block content.'),
      '#cache' => [
        'max-age' => 0,
      ],
    ];
  }

}
```

For blocks that need services, also implement `ContainerFactoryPluginInterface` and add `__construct` + `create()`.

---

## src/Plugin/QueueWorker/ExampleQueueWorker.php

```php
<?php

namespace Drupal\mymodule\Plugin\QueueWorker;

use Drupal\Core\Queue\Attribute\QueueWorker;
use Drupal\Core\Queue\QueueWorkerBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;

/**
 * Processes items from the mymodule queue.
 */
#[QueueWorker(
  id: 'mymodule_example_queue',
  title: new TranslatableMarkup('My Module: Example Queue'),
  cron: ['time' => 30],
)]
class ExampleQueueWorker extends QueueWorkerBase {

  /**
   * {@inheritdoc}
   */
  public function processItem(mixed $data): void {
    // Process $data['nid'] etc.
  }

}
```

Add items to the queue from elsewhere:
```php
$queue = \Drupal::queue('mymodule_example_queue');
$queue->createItem(['nid' => 123]);
```

---

## src/EventSubscriber/ConfigSubscriber.php

```php
<?php

namespace Drupal\mymodule\EventSubscriber;

use Drupal\Core\Config\ConfigCrudEvent;
use Drupal\Core\Config\ConfigEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Responds to configuration change events.
 */
class ConfigSubscriber implements EventSubscriberInterface {

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents(): array {
    return [
      ConfigEvents::SAVE => ['onConfigSave', 0],
    ];
  }

  /**
   * Reacts to a config save event.
   */
  public function onConfigSave(ConfigCrudEvent $event): void {
    if ($event->getConfig()->getName() === 'mymodule.settings') {
      // React to settings change.
    }
  }

}
```

Register in `mymodule.services.yml` with tag `event_subscriber` (see Services section above).

---

## hook_theme() and Twig Integration

In `mymodule.module`:

```php
/**
 * Implements hook_theme().
 */
function mymodule_theme(array $existing, string $type, string $theme, string $path): array {
  return [
    'mymodule_example' => [
      'variables' => [
        'title' => NULL,
        'items' => [],
        'attributes' => NULL,
      ],
    ],
  ];
}
```

Template file: `templates/mymodule-example.html.twig`

```twig
{#
/**
 * @file
 * Template for the My Module example component.
 *
 * Variables:
 * - title: The section title.
 * - items: Array of items to render.
 * - attributes: HTML attributes for the wrapper.
 */
#}
<div{{ attributes.addClass('mymodule-example') }}>
  {% if title %}
    <h2>{{ title }}</h2>
  {% endif %}
  {% if items %}
    <ul>
      {% for item in items %}
        <li>{{ item }}</li>
      {% endfor %}
    </ul>
  {% endif %}
</div>
```

**Theme suggestions** (to allow templates like `mymodule-example--TYPE.html.twig`):

```php
/**
 * Implements hook_theme_suggestions_HOOK().
 */
function mymodule_theme_suggestions_mymodule_example(array $variables): array {
  $suggestions = [];
  if (!empty($variables['type'])) {
    $suggestions[] = 'mymodule_example__' . $variables['type'];
  }
  return $suggestions;
}
```

---

## Tokens

In `mymodule.module`:

```php
use Drupal\Core\Render\BubbleableMetadata;

/**
 * Implements hook_token_info().
 */
function mymodule_token_info(): array {
  return [
    'types' => [
      'mymodule' => [
        'name' => t('My Module'),
        'description' => t('Tokens for My Module.'),
      ],
    ],
    'tokens' => [
      'mymodule' => [
        'site-label' => [
          'name' => t('Site label'),
          'description' => t('A custom site label from My Module.'),
        ],
      ],
    ],
  ];
}

/**
 * Implements hook_tokens().
 */
function mymodule_tokens(
  string $type,
  array $tokens,
  array $data,
  array $options,
  BubbleableMetadata $bubbleable_metadata,
): array {
  $replacements = [];
  if ($type === 'mymodule') {
    foreach ($tokens as $name => $original) {
      if ($name === 'site-label') {
        $config = \Drupal::config('mymodule.settings');
        $bubbleable_metadata->addCacheableDependency($config);
        $replacements[$original] = $config->get('site_label') ?? '';
      }
    }
  }
  return $replacements;
}
```

---

## Drush Commands (Drush 13)

**File location**: `src/Drush/Commands/MyModuleCommands.php`
**Auto-discovered**: No `drush.services.yml` or `drush.command` tag needed. Drush scans `src/Drush/Commands/` in every enabled module automatically.

### Recommended pattern — extends Symfony `Command` (full Drush 13 style)

This is the pattern produced by `drush generate drush:command-file`. Uses native Symfony Console APIs.

```
src/Drush/Commands/MyModuleCommands.php
```

```php
<?php

declare(strict_types=1);

namespace Drupal\mymodule\Drush\Commands;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drush\Commands\AutowireTrait;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

#[AsCommand(
  name: 'mymodule:process',
  description: 'Process My Module items.',
  aliases: ['mmp'],
)]
final class MyModuleCommands extends Command {

  use AutowireTrait;

  /**
   * Constructs a MyModuleCommands object.
   */
  public function __construct(
    // Services autowired by type automatically.
    private readonly EntityTypeManagerInterface $entityTypeManager,
    // Use #[Autowire] when the type alone is ambiguous.
    #[Autowire(service: 'logger.channel.mymodule')]
    private readonly LoggerInterface $logger,
  ) {
    parent::__construct();
  }

  /**
   * {@inheritdoc}
   */
  protected function configure(): void {
    $this
      ->addArgument(
        name: 'type',
        mode: InputOption::VALUE_REQUIRED,
        description: 'Content type to process.',
      )
      ->addOption(
        name: 'limit',
        mode: InputOption::VALUE_OPTIONAL,
        description: 'Max items to process.',
        default: 100,
      )
      ->addUsage('mymodule:process article --limit=50');
  }

  /**
   * {@inheritdoc}
   */
  protected function execute(InputInterface $input, OutputInterface $output): int {
    $type = $input->getArgument('type');
    $limit = (int) $input->getOption('limit');

    $this->logger->notice('Processing {type} (limit: {limit}).', [
      'type' => $type,
      'limit' => $limit,
    ]);

    // Implementation.

    $output->writeln(dt('Done processing @count items.', ['@count' => $limit]));
    return self::SUCCESS;
  }

}
```

### Alternative — extends `DrushCommands` (simpler, still works in Drush 13)

Prefer this when you need Drush-specific helpers (`$this->io()`, `$this->logger()`, `dt()`) or Drush output formatting attributes (`#[CLI\FieldLabels]`, etc.).

```php
<?php

declare(strict_types=1);

namespace Drupal\mymodule\Drush\Commands;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drush\Attributes as CLI;
use Drush\Boot\DrupalBootLevels;
use Drush\Commands\AutowireTrait;
use Drush\Commands\DrushCommands;
use Symfony\Component\Console\Input\InputOption;

final class MyModuleCommands extends DrushCommands {

  use AutowireTrait;

  public function __construct(
    private readonly EntityTypeManagerInterface $entityTypeManager,
  ) {
    parent::__construct();
  }

  #[CLI\Command(name: 'mymodule:process', aliases: ['mmp'])]
  #[CLI\Argument(name: 'type', description: 'Content type to process.')]
  #[CLI\Bootstrap(level: DrupalBootLevels::FULL)]
  public function process(string $type): void {
    $limit = (int) $this->input()->getOption('limit');
    $this->logger()->notice(dt('Processing @type (limit: @limit)', [
      '@type' => $type,
      '@limit' => $limit,
    ]));
    // Implementation.
    $this->io()->success(dt('Done.'));
  }

  /**
   * {@inheritdoc}
   */
  protected function configure(): void {
    // Use configure() for options — #[CLI\Option] is deprecated in Drush 13.
    $this->addOption(
      name: 'limit',
      mode: InputOption::VALUE_OPTIONAL,
      description: 'Max items to process.',
      default: 100,
    );
  }

}
```

### Key Drush 13 rules

| Rule | Detail |
|------|--------|
| **File location** | `src/Drush/Commands/` (not `src/Commands/`) |
| **No services.yml** | Auto-discovered — delete any `drush.command` tag |
| **DI** | `use AutowireTrait` + constructor property promotion |
| **Non-autowireable service** | `#[Autowire(service: 'service.id')]` on the parameter |
| **`#[CLI\Option]` deprecated** | Use `configure()` + `$this->addOption()` instead |
| **`#[CLI\Usage]` deprecated** | Use `configure()` + `$this->addUsage()` instead |
| **`#[CLI\Command]`** | Still required on methods when extending `DrushCommands` |
| **`#[AsCommand]`** | Use on class when extending Symfony `Command` |
| **Legacy still works** | `drush.services.yml` + `drush.command` tag still loads, but is deprecated |

---

## Common hook_preprocess Patterns

```php
/**
 * Implements hook_preprocess_HOOK() for node templates.
 */
function mymodule_preprocess_node(array &$variables): void {
  /** @var \Drupal\node\NodeInterface $node */
  $node = $variables['node'];

  if ($node->bundle() === 'article') {
    $variables['my_custom_var'] = 'value';
  }
}

/**
 * Implements hook_preprocess_HOOK() for paragraph templates.
 */
function mymodule_preprocess_paragraph(array &$variables): void {
  /** @var \Drupal\paragraphs\Entity\Paragraph $paragraph */
  $paragraph = $variables['paragraph'];

  if ($paragraph->bundle() === 'my_paragraph_type') {
    $variables['extra_data'] = mymodule_get_extra_data($paragraph);
  }
}
```

---

## Module Creation Checklist

When building a new module, work through this order:

1. `mymodule.info.yml` — define the module
2. `mymodule.permissions.yml` — define who can do what
3. `mymodule.routing.yml` — define URLs
4. `mymodule.links.menu.yml` + `links.task.yml` — wire up admin UI
5. `config/schema/` — schema for any config
6. `config/install/` — default config values
7. `mymodule.services.yml` — register services and event subscribers
8. `src/Form/SettingsForm.php` — admin form if needed
9. `src/Controller/` — page controllers
10. `src/Plugin/` — blocks, queue workers, field formatters, etc.
11. `mymodule.module` — `hook_theme()`, `hook_help()`, preprocess hooks
12. `mymodule.install` — schema, install/uninstall, updates
13. `mymodule.libraries.yml` + `css/` + `js/` — assets
14. `templates/` — Twig templates
15. Run `lando composer code-sniff` and fix all errors before committing

---

## References
- [Drupal 11 Hook System](https://api.drupal.org/api/drupal/core%21lib%21Drupal%21Core%21Hook%21Attribute%21Hook.php/class/Hook/11)
- [Drupal Module API](https://api.drupal.org/api/drupal/core%21core.api.php/group/module_api/11)
- [Drupal Services & DI](https://api.drupal.org/api/drupal/core%21core.api.php/group/container/11)
- [Plugin API](https://api.drupal.org/api/drupal/core%21core.api.php/group/plugin_api/11)
- [Config API](https://api.drupal.org/api/drupal/core%21lib%21Drupal%21Core%21Config%21Config.php/class/Config/11)
- [drupal-11-coding-standards skill](../drupal-11-coding-standards/SKILL.md)
- [.github/copilot/drupal-modules.md](../../copilot/drupal-modules.md)
