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 || ^11if your module works on both; use^11if you rely on Drupal 11-only APIs.dependencies— machine name format isproject:module. Core modules usedrupal:as the project prefix.package— groups your module on the Extend admin page. UseCustomfor 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:42is invalidated whenever node 42 is saved).max-age— time-to-live in seconds. Use0to disable caching for this element, orCache::PERMANENTfor 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 incontrib/. - The
.info.ymlrequiresname,type: module, andcore_version_requirementat 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.ymlwith explicit argument injection. - Use the
#[Block]PHP attribute for block plugins instead of docblock@Blockannotations. - Use the
#[Hook]attribute in a dedicatedsrc/Hook/class to keep your module fully object-oriented. - Add
#cachemetadata to every render array — contexts, tags, and max-age. - Run
drush crafter any change to.ymlfiles, service definitions, or plugin annotations/attributes.