Skip to content

Drupal Custom Module Development — .info.yml, Routing and Controllers

DodaTech Updated 2026-06-27 9 min read

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'

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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

  1. What is the purpose of core_version_requirement in the .info.yml file, and what happens if it is missing?
  2. A controller returns <p>Hello World</p> as a raw string. How should the developer fix this?
  3. How do you pass a node ID parameter in a route and access it in the controller?
  4. Challenge: Create a minimal module called weather_report that: has a configuration page for an API key and city name, a page at /weather that 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

What files are required for a custom Drupal module?

The minimum is a .info.yml file. For a module that provides a page, you also need a .routing.yml file and a controller class. For permissions, add .permissions.yml. For menu links, add .links.menu.yml. Most modules need multiple YAML files.

Where should I place custom modules?

Place custom modules in modules/custom/. Do not put them in modules/contrib/ (that is for contributed modules downloaded via Composer). Using a separate custom/ directory prevents your modules from being overwritten during updates.

Can I create a module without a controller?

Yes. Some modules only provide hooks, services, or plugins without exposing any page. For example, a module that alters forms via hook_form_alter does not need a controller. Only modules that provide pages need routing and controllers.

How do I add CSS and JS to my module pages?

In your controller, add #attached to the render array: ['#attached' => ['library' => ['my_module/my_library']]]. Then define the library in my_module.libraries.yml and create the corresponding CSS/JS files.

What is the difference between _controller and _form in routing?

_controller points to a controller class method that returns a render array. _form points to a form class that extends FormBase or ConfigFormBase, which handles form building, validation, and submission automatically.

Mini Project

Goal: Build a complete custom module from scratch.

  1. Create modules/custom/task_manager/ directory
  2. Write task_manager.info.yml with metadata and dependencies on node and user
  3. Write task_manager.routing.yml with a page at /task-manager and a settings page at /admin/config/task-manager/settings
  4. Write a controller that displays a list of tasks (nodes of type "Task") in a table
  5. Write task_manager.permissions.yml with "view tasks" and "administer task manager"
  6. Write task_manager.links.menu.yml to add links to the admin toolbar and main menu
  7. Write task_manager.schema.yml for the settings configuration
  8. Create a services.yml with a custom service for task calculations
  9. Enable the module and test the page
  10. Write a TaskManagerController.php that 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