Drupal 10 Module Development: Hands-On Guide to Building Custom Modules from Scratch
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. Runcomposer installand 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>Comments
More Blog
View allBuilding 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.
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
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
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.
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.
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.