Back to Blog
Web Development

Drupal 10 Module Development: Hands-On Guide to Building Custom Modules from Scratch

Claude Directory November 30, 2025
3 views

Demystify Drupal 10 module creation with this practical walkthrough. Build a fully functional 'Hello World' module covering routes, services, plugins, forms, and more—no prior expertise required.

Busting the Myth: Drupal Module Development Isn't Rocket Science

Many developers shy away from Drupal module creation, assuming it's a black art reserved for core contributors. Wrong. With Drupal 10, it's streamlined, leveraging YAML configs, annotations, and PHP classes for rapid prototyping. This guide proves it by walking you through building a complete 'Hello World' module. You'll end up with routes, forms, services, plugins, blocks, and Drush commands—ready for production. Expect real-world tips, pitfalls avoided, and code you can copy-paste.

Prerequisites: Get Your Environment Right

Before diving in, ensure you're set up:

  • Fresh Drupal 10 site: Install via Composer: composer create-project drupal/recommended-project my_site. Run composer install and set up the database.
  • Composer: Essential for dependencies.
  • Drush: composer require drupal/console drush/drush (or use Drupal's built-in).
  • IDE with PHP support: PhpStorm or VS Code with Intelephense for autocompletion.
  • Local server: DDEV, Lando, or Docker for consistency.

Common pitfall: Skipping cache clears. Always run drush cr after changes. Real-world: Use DDEV for teams—ddev start and you're live at https://drupal10.ddev.site.

Step 1: Scaffold Your Module Structure

Drupal modules live in modules/custom/. Create hello_world/ folder:

modules/custom/hello_world/
├── hello_world.info.yml
├── hello_world.routing.yml
├── hello_world.services.yml
├── src/
│   ├── Controller/
│   ├── Form/
│   ├── Service/
│   └── Plugin/
│       └── Block/
├── templates/
└── config/
    └── schema/

Module Info File (hello_world.info.yml): This declares your module.

game: 1.0.0
name: 'Hello World'
type: module
description: 'My first Drupal 10 module.'
core_version_requirement: ^10
package: Custom

Enable it: drush en hello_world -y && drush cr. Myth busted: No more .module files—YAML handles metadata. Pro tip: Use core_version_requirement for semantic versioning to avoid upgrade headaches.

Step 2: Define Routes for Clean URLs

Routes map URLs to controllers. Create hello_world.routing.yml:

hello_world.hello:
  path: '/hello'
  defaults:
    _controller: '\\Drupal\\hello_world\\Controller\\HelloController::hello'
    _title: 'Hello Page'
  requirements:
    _permission: 'access content'

Visit /hello—error? Cache clear! This sets up MVC-like structure. Real-world: For APIs, add _format: json and _access: 'root' for anonymous endpoints.

Step 3: Build Controllers to Handle Requests

Controllers process routes. src/Controller/HelloController.php:

<?php

namespace Drupal\\hello_world\\Controller;

use Drupal\\Core\\Controller\\ControllerBase;

class HelloController extends ControllerBase {

  public function hello() {
    return [
      '#markup' => $this->t('Hello, Drupal 10!'),
    ];
  }

}

Bust the myth: Controllers aren't bloated—keep them thin, delegate to services. Add query params? Inject Request and access $request->query->get('name').

Step 4: Inject Services for Reusability

Services encapsulate logic. Define in hello_world.services.yml:

services:
  hello_world.hello_service:
    class: Drupal\\hello_world\\Service\\HelloService
    arguments: ['@logger.factory']

Implement src/Service/HelloService.php:

<?php

namespace Drupal\\hello_world\\Service;

use Drupal\\Core\\Logger\\LoggerChannelFactoryInterface;

class HelloService {

  protected $logger;

  public function __construct(LoggerChannelFactoryInterface $logger) {
    $this->logger = $logger->get('hello_world');
  }

  public function greet($name) {
    $message = t('Hello @name!', ['@name' => $name]);
    $this->logger->info($message);
    return $message;
  }

}

Inject into controller: Add to constructor. Example from Video Embed Field module—services parse external data cleanly. Pitfall: Forget @ in YAML args? Instant DI fail.

Step 5: Events and Subscribers for Decoupled Logic

React to core events without hacking. Create src/EventSubscriber/HelloSubscriber.php:

<?php

namespace Drupal\\hello_world\\EventSubscriber;

use Symfony\\Component\\EventDispatcher\\EventSubscriberInterface;
use Drupal\\Core\\Session\\AccountInterface;

class HelloSubscriber implements EventSubscriberInterface {

  public function checkUserLogins(\\Drupal\\Core\\Session\\Event\\AuthCacheContextEvent $event) {
    /** @var \\Drupal\\Core\\Session\\AccountInterface $account */
    $account = $event->getAccount();
    if ($account->isAnonymous()) {
      $event->addCacheableDependency($account);
    }
  }

  public static function getSubscribedEvents() {
    return [
      AccountInterface::EVENT_AUTH_CACHE_CONTEXT => 'checkUserLogins',
    ];
  }

}

Register in services.yml: tags: [ 'event_subscriber' ]. Real-world: Alter node saves or user logins for custom workflows.

Step 6: Plugins for Extensibility

Drupal's plugin system shines for derivatives. Example Greeting plugin src/Plugin/Greeting/HelloGreeter.php:

<?php

namespace Drupal\\hello_world\\Plugin\\Greeting;

use Drupal\\hello_world\\Annotation\\HelloGreeter;

/**
 * @HelloGreeter(
 *   id = "hello",
 *   label = "Hello Greeter"
 * )
 */
class HelloGreeter extends GreeterBase {

  public function greet() {
    return $this->t('Hello!');
  }

}

Define annotation base class. Bust myth: Plugins aren't just fields—use for blocks, field types, anywhere extensible. Real app: Custom media processors.

Step 7: Forms for User Input

src/Form/HelloForm.php:

<?php

namespace Drupal\\hello_world\\Form;

use Drupal\\Core\\Form\\FormBase;

class HelloForm extends FormBase {

  public function getFormId() {
    return 'hello_world_form';
  }

  public function buildForm(array $form, array &$form_state) {
    $form['name'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Name'),
    ];
    $form['actions']['submit'] = [
      '#type' => 'submit',
      '#value' => $this->t('Greet'),
    ];
    return $form;
  }

  public function submitForm(array &$form, FormStateInterface $form_state) {
    $this->messenger()->addMessage($this->t('Hello @name!', ['@name' => $form_state->getValue('name')]));
  }

}

Route to _form: '\\Drupal\\hello_world\\Form\\HelloForm'. Validation? Override validateForm. Production: CSRF-proof by default.

Step 8: Blocks for Layout Flexibility

src/Plugin/Block/HelloBlock.php:

<?php

namespace Drupal\\hello_world\\Plugin\\Block;

use Drupal\\Core\\Block\\BlockBase;

/**
 * @Block(
 *   id = "hello_block",
 *   admin_label = "Hello Block",
 * )
 */
class HelloBlock extends BlockBase {

  public function build() {
    return [
      '#markup' => $this->t('Hello Block!'),
    ];
  }

}

Place via Layout Builder. Context-aware? Inject services.

Step 9: Twig Templates for Theming

templates/hello.html.twig:

<div class="hello">
  <h1>{{ greeting }}</h1>
</div>

Controller: ['#theme' => 'hello', '#greeting' => 'World']. Cache? Use #cache contexts.

Step 10: Hooks for Legacy Power

Still useful. In hello_world.module:

<?php

function hello_world_help($route_name, RouteMatchInterface $route_match) {
  return '<p>' . t('Hello module help.') . '</p>';
}

Permissions, Menus, Local Tasks

Add to info.yml: permissions. Menus via routing _title_menu. Tasks: tab_parent.

Drush Commands for CLI Magic

src/Commands/HelloCommands.php extends DrushCommands. Real-world: Deploy scripts.

Best Practices & Deployment

  • Test with PHPUnit.
  • Use Rector for upgrades.
  • Git: Version control everything.
  • Ship to Drupal.org.

This module now handles user input, logs, and extends core. Scale to e-commerce integrations or APIs. Total words: ~1200. You're ready.

<div style="text-align: center; margin-top: 2rem;"> <a href="https://cursor.directory/drupal-10-module-development" target="_blank" rel="noopener noreferrer" class="view-full-resource-btn" style="display: inline-block; background-color: #f97316; color: white; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; transition: background-color 0.2s;">View Full Resource</a> </div>
GitHub Project

Comments

More Blog

View all
Claude for Developers

Building Voice Agents with Claude API and ElevenLabs: Conversational AI Guide

Build natural voice agents combining Claude API's superior reasoning with ElevenLabs' lifelike TTS. This end-to-end guide creates a conversational web app with STT, AI chat, and speech synthesis.

C
Claude Directory
2
Model Comparisons

Claude vs Mistral Large 2: 2025 Data Analysis Benchmarks and Use Cases

As data volumes explode in 2025, choosing between Claude's reasoning depth and Mistral Large 2's efficiency is critical. We benchmark SQL generation, visualizations, and large datasets to reveal the w

C
Claude Directory
1
Enterprise

Claude Enterprise for Cybersecurity: Threat Modeling and Incident Response

In the high-stakes world of cybersecurity, rapid threat modeling and incident response can mean the difference between containment and catastrophe. Discover how Claude Enterprise empowers security tea

C
Claude Directory
1
Claude Code

Claude Code in VS Code: Custom Commands for Refactoring Large Codebases

Refactoring sprawling codebases manually? Harness Claude Code's power in VS Code with custom commands to automate AI-driven refactors across TypeScript and Python projects—saving hours of drudgery.

C
Claude Directory
1
Claude for Developers

Claude SDK Rust for Blockchain: Smart Contract Auditing Agents

Build blazing-fast smart contract auditing agents in Rust using the Claude SDK. Harness Claude's reasoning to scan Solidity code for vulnerabilities like reentrancy and overflows.

C
Claude Directory
1
Claude Best Practices

Advanced Claude Artifacts: Collaborative Editing in Multi-User Sessions

Elevate team productivity with Claude Artifacts in multi-user projects—enable real-time iterative editing for code reviews and docs without leaving the interface.

C
Claude Directory
1