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.
```yaml
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`:
```yaml
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
<?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`:
```yaml
services:
hello_world.hello_service:
class: Drupal\\hello_world\\Service\\HelloService
arguments: ['@logger.factory']
```
Implement `src/Service/HelloService.php`:
```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](https://github.com/backdrop-contrib/video_embed_field)—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
<?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
<?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
<?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
<?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
<?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>