How to Create a Custom Module in Drupal 10
By Pawan Singh · Jun 2026
A custom module is how you add site-specific functionality Drupal core and contrib don't cover — a route, a service, a plugin, a hook into an existing subsystem. This builds one from nothing to a working, testable module.
Module Directory Structure
Custom modules live in web/modules/custom/, one folder per module, named to match the module's machine name:
web/modules/custom/my_module/
my_module.info.yml
my_module.routing.yml
my_module.services.yml
src/Controller/MyModuleController.php
src/Plugin/Block/MyModuleBlock.php
The .info.yml File
This is what makes Drupal recognize the module and show it in the modules list:
name: My Module
type: module
description: 'A custom example module.'
core_version_requirement: ^10 || ^11
package: Custom
Add a dependencies: key listing any other module (drupal:node, drupal:views) yours requires — Drupal will refuse to enable your module until those are enabled first.
Define a Route
my_module.routing.yml maps a URL path to a controller method:
my_module.hello:
path: '/my-module/hello'
defaults:
_controller: '\Drupal\my_module\Controller\MyModuleController::hello'
_title: 'Hello'
requirements:
_permission: 'access content'
_permission is the access control gate for this route — use _permission: 'access content' for anything anonymous users can see, a custom permission key (defined in a .permissions.yml file) for anything role-restricted, or _access: 'TRUE' only for genuinely public routes with no permission check at all.
Write the Controller
namespace Drupal\my_module\Controller;
use Drupal\Core\Controller\ControllerBase;
class MyModuleController extends ControllerBase
{
public function hello()
{
return [
'#markup' => $this->t('Hello from a custom module.'),
];
}
}
Returning a render array (not a raw string) lets Drupal's render pipeline apply caching, theming, and any alter hooks other modules define — this is the idiomatic return type for anything a controller sends to the browser.
Injecting Services Instead of Using Static Calls
Rather than reaching for \Drupal::entityTypeManager() statically inside a controller (which works, but is harder to unit test), inject the service through the constructor:
class MyModuleController extends ControllerBase
{
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager
) {}
public static function create(ContainerInterface $container): static
{
return new static($container->get('entity_type.manager'));
}
public function hello()
{
$count = $this->entityTypeManager->getStorage('node')->getQuery()
->accessCheck(TRUE)
->count()
->execute();
return ['#markup' => $this->t('There are @count nodes.', ['@count' => $count])];
}
}
ControllerBase::create() is what Drupal actually calls to build your controller, using its dependency injection container — this is the same pattern Drupal core's own controllers use, and it's what makes swapping in a mock service during a unit test straightforward.
Creating a Custom Block Plugin
A block is one of the most common things a custom module adds:
namespace Drupal\my_module\Plugin\Block;
use Drupal\Core\Block\BlockBase;
/**
* @Block(
* id = "my_module_greeting",
* admin_label = @Translation("Greeting block")
* )
*/
class GreetingBlock extends BlockBase
{
public function build()
{
return ['#markup' => $this->t('Hello, visitor.')];
}
}
Once the module is enabled, this block appears in Drupal's block layout UI (Structure → Block layout) exactly like any core or contrib block, placeable in any region through the admin interface without touching a template.
Enable the Module
vendor/bin/drush en my_module -y
After enabling, clear the cache whenever you change routing, services, or plugin definitions — Drupal caches all of these aggressively for performance, and a change that "isn't taking effect" is very often just a stale cache:
vendor/bin/drush cr
Common Issues
- "The website encountered an unexpected error" after adding a route — almost always a YAML syntax error (indentation is significant) or a class/method name mismatch between
.routing.ymland the actual controller. - New route/block/plugin not appearing at all — run
drush cr; Drupal's plugin and routing systems are both cached and won't pick up new definitions until the cache is cleared. - "Call to a member function on null" in a service-injected class — the service ID passed to
$container->get()increate()doesn't match an actual registered service; checkdrush devel:servicesor core's own service definitions for the exact ID. - Module won't enable, citing a missing dependency — a module listed under
dependencies:in.info.ymlisn't installed or enabled yet; enable it first.
Need a custom Drupal module built for a specific business requirement? Get in touch — I work as a freelance Drupal developer.