CCelestiallines.com
← Back to blog

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.yml and 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() in create() doesn't match an actual registered service; check drush devel:services or core's own service definitions for the exact ID.
  • Module won't enable, citing a missing dependency — a module listed under dependencies: in .info.yml isn'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.

Comments