Drupal 11 Custom Module Development: From Scaffold to First Route

Every Drupal project eventually needs custom code. Whether it is a new content type with business logic, a custom REST endpoint, or a block that pulls data from an external API — the answer is a custom module. This guide walks through building a complete Drupal 11 module from scratch: the required file structure, routing, a controller, a service, a custom block, and hooks implemented with the PHP attribute syntax introduced in Drupal 11.

All examples use PHP 8.3+, Drupal 11 conventions, and the web/ Composer project layout.


Where Custom Modules Live

In a Composer-based Drupal 11 project the correct location for custom modules is:

web/modules/custom/your_module/

Never put custom modules inside web/modules/contrib/ — that directory is managed by Composer and will be overwritten. The custom/ subdirectory is ignored by Drupal's packaging toolchain and is the community-standard location.


Step 1: The .info.yml File

Every Drupal module starts with a MODULE_NAME.info.yml file. Create web/modules/custom/hello_world/hello_world.info.yml:

name: Hello World
description: A starter module demonstrating Drupal 11 module development patterns.
type: module
package: Custom
core_version_requirement: ^10.3 || ^11
dependencies:
  - drupal:user

Key fields:

  • type: module — required; distinguishes modules from themes and profiles.
  • core_version_requirement — use ^10.3 || ^11 if your module works on both; use ^11 if you rely on Drupal 11-only APIs.
  • dependencies — machine name format is project:module. Core modules use drupal: as the project prefix.
  • package — groups your module on the Extend admin page. Use Custom for site-specific modules.

That is the minimum. Drupal will now recognise the module and let you enable it. Everything else is optional or added on demand.


Step 2: A Route and Controller

Routing in Drupal 11 follows the Symfony routing model. Define routes in hello_world.routing.yml:

hello_world.page:
  path: '/hello'
  defaults:
    _controller: '\Drupal\hello_world\Controller\HelloWorldController::page'
    _title: 'Hello World'
  requirements:
    _permission: 'access content'

The route key (hello_world.page) is the route name — it must be unique across the entire site. The convention is MODULE_NAME.ROUTE_SUFFIX.

Permissions available in requirements:

  • _permission: 'permission machine name' — requires the user to have this permission.
  • _role: 'administrator' — requires a specific role.
  • _user_is_logged_in: 'TRUE' — requires authentication.
  • _access: 'TRUE' — open access, no restriction.

Now create the controller at src/Controller/HelloWorldController.php:

<?php

declare(strict_types=1);

namespace Drupal\hello_world\Controller;

use Drupal\Core\Controller\ControllerBase;

final class HelloWorldController extends ControllerBase
{
    public function page(): array
    {
        return [
            '#type'   => 'markup',
            '#markup' => $this->t('Hello, World!'),
        ];
    }
}

Drupal controllers return a render array, not a response object (unless you explicitly return a Response). The ControllerBase parent class provides convenience methods: $this->t() for translation, $this->currentUser(), $this->entityTypeManager(), and others — all using the service container internally.

Enable the module and visit /hello to see the output.

drush en hello_world -y
drush cr

Step 3: Injecting Services (Dependency Injection)

Drupal's service container follows Symfony conventions. For controllers that extend ControllerBase, the cleanest approach is to override create() and inject services through the constructor:

<?php

declare(strict_types=1);

namespace Drupal\hello_world\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Datetime\DateFormatterInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

final class HelloWorldController extends ControllerBase
{
    public function __construct(
        private readonly DateFormatterInterface $dateFormatter,
    ) {}

    public static function create(ContainerInterface $container): static
    {
        return new static(
            $container->get('date.formatter'),
        );
    }

    public function page(): array
    {
        $time = $this->dateFormatter->format(\Drupal::time()->getRequestTime(), 'long');

        return [
            '#type'   => 'markup',
            '#markup' => $this->t('Hello! The current time is @time.', ['@time' => $time]),
        ];
    }
}

For custom services, declare them in hello_world.services.yml:

services:
  hello_world.greeter:
    class: Drupal\hello_world\Service\Greeter
    arguments:
      - '@current_user'
      - '@config.factory'

The corresponding service class:

<?php

declare(strict_types=1);

namespace Drupal\hello_world\Service;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Session\AccountInterface;

final class Greeter
{
    public function __construct(
        private readonly AccountInterface $currentUser,
        private readonly ConfigFactoryInterface $configFactory,
    ) {}

    public function greet(): string
    {
        $siteName = $this->configFactory
            ->get('system.site')
            ->get('name');

        return sprintf(
            'Hello, %s! Welcome to %s.',
            $this->currentUser->getDisplayName(),
            $siteName,
        );
    }
}

Step 4: A Custom Block Plugin

Blocks in Drupal 11 are plugins. Create a block at src/Plugin/Block/HelloBlock.php:

<?php

declare(strict_types=1);

namespace Drupal\hello_world\Plugin\Block;

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

#[Block(
    id: 'hello_world_block',
    admin_label: new TranslatableMarkup('Hello World Block'),
    category: new TranslatableMarkup('Custom'),
)]
final class HelloBlock extends BlockBase
{
    public function build(): array
    {
        return [
            '#type'   => 'markup',
            '#markup' => $this->t('Hello from a block!'),
        ];
    }
}

The #[Block(...)] attribute replaces the old @Block docblock annotation. Both syntaxes are supported in Drupal 11, but PHP attributes are the forward-looking approach — Drupal core itself uses them throughout, and docblock annotations will be deprecated in a future release.

After a cache clear the block will appear in Structure → Block layout.


Step 5: Hooks with PHP Attributes

Drupal 11 introduced the #[Hook] PHP attribute, allowing you to implement hooks as methods on any class rather than as top-level functions in a .module file. This is the most significant developer-experience change in Drupal 11.

Create a hook class at src/Hook/HelloWorldHooks.php:

<?php

declare(strict_types=1);

namespace Drupal\hello_world\Hook;

use Drupal\Core\Hook\Attribute\Hook;

final class HelloWorldHooks
{
    /**
     * Implements hook_help().
     */
    #[Hook('help')]
    public function help(string $routeName): ?string
    {
        if ($routeName === 'hello_world.page') {
            return '<p>' . t('A simple hello world page.') . '</p>';
        }
        return null;
    }

    /**
     * Implements hook_page_attachments().
     */
    #[Hook('page_attachments')]
    public function pageAttachments(array &$attachments): void
    {
        // Only attach our library on the hello page
        $route = \Drupal::routeMatch()->getRouteName();
        if ($route === 'hello_world.page') {
            $attachments['#attached']['library'][] = 'hello_world/hello_styles';
        }
    }
}

The #[Hook('help')] attribute maps to hook_help(). Drupal discovers these automatically — no registration required, no entry in hello_world.module. The hook method signature must match the hook's documented signature exactly.

You can still use the traditional hello_world.module file for hooks that cannot use attributes (notably hook_install, hook_uninstall, and some schema hooks), but for the majority of hooks the attribute approach is cleaner and keeps your module entirely class-based.


Step 6: A Custom Permission

Define custom permissions in hello_world.permissions.yml:

administer hello world:
  title: 'Administer Hello World'
  description: 'Configure the Hello World module settings.'
  restrict access: true

Use this permission in your routes:

hello_world.admin:
  path: '/admin/config/hello-world'
  defaults:
    _controller: '\Drupal\hello_world\Controller\HelloWorldController::adminPage'
    _title: 'Hello World Settings'
  requirements:
    _permission: 'administer hello world'

Step 7: Cache Metadata

One of the most important Drupal 11 concepts for custom module developers is cache metadata. Every render array can carry cache tags, cache contexts, and a max-age. Get this wrong and you will see stale content or site-wide cache invalidation on every page load.

public function page(): array
{
    // Vary by user role — different roles see different content
    // Cache tag: invalidate whenever system.site config changes
    return [
        '#type'   => 'markup',
        '#markup' => $this->t('Hello, @name!', [
            '@name' => $this->currentUser()->getDisplayName(),
        ]),
        '#cache'  => [
            'contexts' => ['user'],           // Vary per user
            'tags'     => ['user:' . $this->currentUser()->id()],
            'max-age'  => 3600,
        ],
    ];
}

The three cache metadata keys:

  • contexts — dimensions along which the output varies (e.g. url, user, user.roles, languages). Each context multiplies the number of cached variants.
  • tags — arbitrary strings that can be invalidated externally (e.g. node:42 is invalidated whenever node 42 is saved).
  • max-age — time-to-live in seconds. Use 0 to disable caching for this element, or Cache::PERMANENT for no expiry.

Complete Module File Structure

web/modules/custom/hello_world/
├── hello_world.info.yml          # Module metadata
├── hello_world.routing.yml       # URL routes
├── hello_world.services.yml      # Service definitions
├── hello_world.permissions.yml   # Custom permissions
├── hello_world.libraries.yml     # CSS/JS library definitions
├── hello_world.module            # Procedural hooks (use sparingly)
└── src/
    ├── Controller/
    │   └── HelloWorldController.php
    ├── Hook/
    │   └── HelloWorldHooks.php   # PHP attribute-based hooks
    ├── Plugin/
    │   └── Block/
    │       └── HelloBlock.php    # Block plugin with #[Block] attribute
    └── Service/
        └── Greeter.php           # Custom service

Useful Drush Commands During Development

# Enable your module
drush en hello_world -y

# Rebuild the cache (required after adding/changing .yml files)
drush cache:rebuild

# List all routes (useful for debugging routing issues)
drush route:list | grep hello

# Check service container
drush php:eval "var_dump(\Drupal::hasService('hello_world.greeter'));"

# Run module-specific tests
drush test:run --module=hello_world

Summary Checklist

  • Place custom modules in web/modules/custom/, never in contrib/.
  • The .info.yml requires name, type: module, and core_version_requirement at minimum.
  • Define routes in MODULE.routing.yml; controllers return render arrays, not HTML strings.
  • Use constructor injection with create() override for services in controllers.
  • Declare custom services in MODULE.services.yml with explicit argument injection.
  • Use the #[Block] PHP attribute for block plugins instead of docblock @Block annotations.
  • Use the #[Hook] attribute in a dedicated src/Hook/ class to keep your module fully object-oriented.
  • Add #cache metadata to every render array — contexts, tags, and max-age.
  • Run drush cr after any change to .yml files, service definitions, or plugin annotations/attributes.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.