Drupal Custom Module Development — .info.yml, Routing and Controllers
In this tutorial, you'll learn how to create custom Drupal modules from scratch: the module directory structure, the .info.yml file, routing YAML for URL paths, controller classes that return content, and registering menu links and permissions for your module.
What You'll Learn
- Module directory structure under modules/custom/
- The .info.yml file: name, type, core_version_requirement, description, configure, dependencies
- Routing YAML: paths, controllers, titles, access requirements
- Controller classes extending ControllerBase with render arrays
- Route parameters like passing node IDs
- Access checking with _permission, _role, and custom access checkers
- Menu links YAML for admin and front-end navigation
- Tasks and local actions YAML
- Permissions YAML for custom module permissions
- Module dependencies in .info.yml
- Services YAML for Dependency Injection
- Schema YAML for configuration storage
Why It Matters
Contributed modules cover many use cases, but every project needs custom functionality. You might need a custom content workflow, a specialized reporting page, integration with an external API, or custom business logic. Writing custom modules gives you complete control over your site's behavior without relying on fragile hacks or untested contributed modules. Understanding module development is the line between being a Drupal site Builder and being a Drupal developer.
Real-World Use
A real estate company needs a custom property listing module that integrates with their internal CRM API. No contributed module meets their requirements. A developer creates a custom module with a configuration page for API credentials, a controller that fetches and displays property data, custom permissions for agents versus admins, and a Drush command to sync listings nightly. The module is clean, maintainable, and survives Drupal core updates because it follows standard module development patterns.
Learning Path
flowchart LR A[Security Hardening] --> B[Custom Module Development] B --> C[.info.yml Structure] C --> D[Routing YAML] D --> E[Controllers] E --> F[Menu and Permissions] F --> G[Services and DI] G --> H[Hooks and APIs]
Module Structure
Every Drupal module lives in its own directory under modules/custom/. The naming convention is module_machine_name using lowercase and underscores.
modules/custom/my_module/
|-- my_module.info.yml # Module metadata (required)
|-- my_module.routing.yml # URL routes (for pages)
|-- my_module.links.menu.yml # Menu links
|-- my_module.links.task.yml # Local tasks (tabs)
|-- my_module.links.action.yml # Local actions (buttons)
|-- my_module.permissions.yml # Custom permissions
|-- my_module.services.yml # Service definitions
|-- my_module.schema.yml # Configuration schema
|-- src/
| |-- Controller/
| | |-- MyController.php # Controller classes
| |-- Form/
| | |-- MyConfigForm.php # Configuration forms
| |-- Plugin/
| |-- Service/
|-- templates/
| |-- my-template.html.twig # Twig templates
|-- my_module.module # Hook implementations
The .info.yml File
Every module needs an .info.yml file. This is the module's identity card.
# my_module.info.yml
name: 'My Custom Module'
type: module
core_version_requirement: ^10 || ^11
description: 'Provides custom functionality for property listings.'
package: Custom
configure: my_module.settings
dependencies:
- drupal:node
- drupal:views
- drupal:taxonomy
Let us break down each key:
- name: Human-readable module name shown in the Extend page
- type: Must be
module(other options:theme,profile) - core_version_requirement: Which Drupal versions support this module
- description: Short description shown in the Extend page
- package: Group name in the Extend page (Custom, Administration, etc.)
- configure: Route to the configuration page (optional)
- dependencies: Other modules this module requires
Routing YAML
The routing file maps URL paths to controller methods:
# my_module.routing.yml
my_module.my_page:
path: '/my-module/page'
defaults:
_controller: '\Drupal\my_module\Controller\MyController::page'
_title: 'My Module Page'
requirements:
_permission: 'access content'
my_module.settings:
path: '/admin/config/my-module/settings'
defaults:
_form: '\Drupal\my_module\Form\MyConfigForm'
_title: 'My Module Settings'
requirements:
_permission: 'administer site configuration'
Route parameters:
# Route with a parameter
my_module.node_detail:
path: '/my-module/node/{node}'
defaults:
_controller: '\Drupal\my_module\Controller\MyController::nodeDetail'
_title: 'Node Detail'
requirements:
_permission: 'access content'
node: \d+ # Only match numeric IDs
Routes follow this pattern:
- Route name:
module_name.machine_name(unique identifier) - path: URL path with optional
{parameter}placeholders - _controller or _form: Class::method to handle the request
- _title: Page title
- _permission or _role: Access requirement
Controller Classes
Controllers handle requests and return responses. Extend ControllerBase for convenience methods and services.
<?php
namespace Drupal\my_module\Controller;
use Drupal\Core\Controller\ControllerBase;
class MyController extends ControllerBase {
public function page() {
// A simple page with a render array
return [
'#markup' => '<p>Welcome to My Module page.</p>',
'#prefix' => '<div class="my-module-wrapper">',
'#suffix' => '</div>',
];
}
public function nodeDetail($node) {
// $node is the node ID from the route
$node_storage = $this->entityTypeManager()
->getStorage('node');
$node_entity = $node_storage->load($node);
if (!$node_entity) {
return [
'#markup' => $this->t('Node not found.'),
];
}
return [
'#theme' => 'node',
'#node' => $node_entity,
];
}
}
Render Arrays
Controllers return render arrays — nested arrays of elements that Twig renders into HTML:
<?php
public function complexPage() {
// Build a table render array
$header = ['Title', 'Status', 'Author'];
$rows = [];
$nodes = $this->entityTypeManager()
->getStorage('node')
->loadMultiple();
foreach ($nodes as $node) {
$rows[] = [
$node->getTitle(),
$node->isPublished() ? 'Published' : 'Unpublished',
$node->getOwner()->getDisplayName(),
];
}
return [
'#type' => 'table',
'#header' => $header,
'#rows' => $rows,
'#empty' => $this->t('No content found.'),
'#prefix' => '<div class="my-module-table">',
'#suffix' => '</div>',
];
}
Access Checking
Control who can access routes:
# By permission
requirements:
_permission: 'my custom permission'
# By role
requirements:
_role: 'administrator'
# Custom access checker
requirements:
_custom_access: '\Drupal\my_module\Access\MyAccessCheck::access'
Menu Links YAML
Add links to Drupal's menu system:
# my_module.links.menu.yml
my_module.admin:
title: 'My Module'
description: 'Configure My Module settings.'
route_name: my_module.settings
parent: system.admin_config_development
weight: 10
my_module.front:
title: 'My Module Page'
description: 'View the My Module page.'
route_name: my_module.my_page
menu_name: main
weight: 50
Tasks and Local Actions
Local tasks appear as tabs, local actions as action buttons:
# my_module.links.task.yml
my_module.view:
title: 'View'
route_name: my_module.my_page
base_route: my_module.my_page
weight: -10
my_module.settings_tab:
title: 'Settings'
route_name: my_module.settings
base_route: my_module.my_page
weight: 10
# my_module.links.action.yml
my_module.add:
title: 'Add item'
route_name: my_module.add_page
appears_on:
- my_module.my_page
Permissions YAML
Define custom permissions for your module:
# my_module.permissions.yml
'my module special access':
title: 'Access special features'
description: 'Allows users to access special features of My Module.'
restrict access: true
'my module administer':
title: 'Administer My Module'
description: 'Allows users to configure My Module settings.'
restrict access: true
'my module view reports':
title: 'View reports'
description: 'Allows users to view My Module reports.'
restrict access: false
Module Dependencies
Declare dependencies in .info.yml:
# Composer dependencies are declared in composer.json
dependencies:
- drupal:node
- drupal:views
- drupal:taxonomy
- contrib:pathauto
And in your module's composer.json:
{
"name": "drupal/my_module",
"type": "drupal-module",
"require": {
"drupal/pathauto": "^1.0"
}
}
Services YAML
Register custom services for dependency injection:
# my_module.services.yml
services:
my_module.api_client:
class: Drupal\my_module\Service\ApiClient
arguments: ['@http_client', '@config.factory']
<?php
namespace Drupal\my_module\Service;
use GuzzleHttp\ClientInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
class ApiClient {
protected $httpClient;
protected $configFactory;
public function __construct(
ClientInterface $http_client,
ConfigFactoryInterface $config_factory
) {
$this->httpClient = $http_client;
$this->configFactory = $config_factory;
}
public function fetchData() {
$api_key = $this->configFactory
->get('my_module.settings')
->get('api_key');
$response = $this->httpClient->get(
'https://api.example.com/data',
['headers' => ['Authorization' => 'Bearer ' . $api_key]]
);
return json_decode($response->getBody(), true);
}
}
Schema YAML
Define configuration schema so Drupal knows how to validate and translate your module's configuration:
# my_module.schema.yml
my_module.settings:
type: config_object
label: 'My Module settings'
mapping:
api_key:
type: string
label: 'API Key'
endpoint:
type: uri
label: 'API Endpoint'
sync_interval:
type: integer
label: 'Sync interval (minutes)'
default: 60
Common Mistakes
Invalid .info.yml syntax: YAML is strict about indentation. A single extra space breaks the file. Validate your YAML with a linter before enabling the module.
Wrong namespace in controllers: The namespace must match the directory structure:
\Drupal\{module_name}\Controller\{ControllerName}. If the namespace does not match, Drupal throws a ClassNotFoundException.Returning HTML strings instead of render arrays: Render arrays are the Drupal way. Return
['#markup' => '<p>Hello</p>']instead of'<p>Hello</p>'. Render arrays allow Caching, theming, and attachment of assets.Forgetting cache metadata: Render arrays must include cache tags and contexts so that Drupal knows when to invalidate the cache. Without them, users might see stale content.
Hardcoding permissions strings in routing without declaring them: If you use
_permission: 'my custom permission'without declaring it in.permissions.yml, the permission does not exist and access is denied.
Practice Questions
- What is the purpose of
core_version_requirementin the .info.yml file, and what happens if it is missing? - A controller returns
<p>Hello World</p>as a raw string. How should the developer fix this? - How do you pass a node ID parameter in a route and access it in the controller?
- Challenge: Create a minimal module called
weather_reportthat: has a configuration page for an API key and city name, a page at/weatherthat displays the current temperature (use a mock API), a custom permission "view weather reports", a menu link in the main navigation, and a Drush command to update the weather cache.
FAQ
Mini Project
Goal: Build a complete custom module from scratch.
- Create
modules/custom/task_manager/directory - Write
task_manager.info.ymlwith metadata and dependencies on node and user - Write
task_manager.routing.ymlwith a page at/task-managerand a settings page at/admin/config/task-manager/settings - Write a controller that displays a list of tasks (nodes of type "Task") in a table
- Write
task_manager.permissions.ymlwith "view tasks" and "administer task manager" - Write
task_manager.links.menu.ymlto add links to the admin toolbar and main menu - Write
task_manager.schema.ymlfor the settings configuration - Create a services.yml with a custom service for task calculations
- Enable the module and test the page
- Write a
TaskManagerController.phpthat accepts an optional{nid}route parameter and shows task details
What's Next
Now that you can create custom modules, proceed to Drupal hooks to learn how modules interact with Drupal's event system. Then explore Drupal API for render arrays, entity queries, and the service container.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro