Think of a fresh Drupal install as an empty house with excellent plumbing, wiring, and structural bones — but no furniture. Contributed modules from drupal.org are like buying furniture off the shelf: fast, but not always the exact shape your project needs. A custom module is what you build yourself when the shelf doesn't have what you're looking for: a bespoke page, a unique integration, a rule specific to your business. This lesson walks you from a completely empty folder to a real, working module you can visit in a browser.
What you'll learn in this lesson
- Why you'd write a custom module instead of reaching for a contributed one
- What you need installed before you start (and why each tool matters)
- The handful of files every module is built from, and what each one is responsible for
- How to build and enable a real, working "Hello World" module from scratch
Why write a custom module at all?
Four reasons come up constantly in real projects:
- Customization — a workflow or integration specific enough to your project that no contributed module fits it out of the box.
- Reusability — package the logic once, drop it into every site that needs it, or share it with the community on drupal.org.
- Performance — a lightweight, purpose-built module beats installing three heavyweight contributed modules just to get one feature.
- Understanding — writing modules is how you actually learn Drupal's hooks, services, and render pipeline, rather than just configuring someone else's.
Prerequisites
Before you write a line of code, make sure you have:
- Basic PHP (object-oriented style is what modern Drupal expects) and enough YAML to read a config file comfortably.
- A local Drupal 10 or 11 site — DDEV, Lando, or any equivalent stack works fine.
- Composer, and ideally Drush, available on the command line.
- A code editor and Git — you don't strictly need Git to follow this lesson, but you'll want it the moment this module is worth keeping.
No prior module-building experience is assumed — this is genuinely the starting line.
The shape every module takes
A Drupal module is just a folder living under /modules/custom/ (custom, project-specific code) or /modules/contrib/ (code you downloaded from someone else). Drupal doesn't care what's inside until it finds one required file:
*.info.yml— the only file that's truly mandatory. It's how Drupal discovers your module exists at all, and declares its name, description, and core version compatibility.*.module— an optional procedural PHP file, traditionally where hook implementations live.*.routing.yml— optional; defines URL paths and which controller handles each one.src/— optional; holds PHP classes (controllers, services, plugins) following PSR-4 autoloading.config/,templates/,css/,js/— optional folders for default configuration, Twig overrides, and front-end assets.
*.info.yml is a complete, installable Drupal module — it just won't do anything yet. Every other file on this list only exists because you added a capability that needs it.Building "Hello World" step by step
The classic first module: visiting /hello should print "It works!". Small enough to type from scratch, but it touches every piece you'll use in every module after this one — an info file, a route, and a controller.
Step 1 — create the module folder
Inside your site's modules/custom/ directory, create a folder named hello_world. Stick to lowercase letters and underscores — this becomes your module's machine name, and it shows up in namespaces, service names, and hook function names for the rest of the module's life.
Step 2 — declare the module: hello_world.info.yml
name: 'Hello World'
type: module
description: 'This is a Hello world module'
package: Custom
core_version_requirement: ^10 || ^11
This is the file that registers your module with Drupal — the moment it exists with valid YAML, "Hello World" appears in the module list at /admin/modules, ready to be enabled.
Step 3 — define a route: hello_world.routing.yml
hello_world.hello_world_page:
path: '/hello'
defaults:
_title: 'Hello World Page'
_controller: '\Drupal\hello_world\Controller\HelloWorldPage'
requirements:
_permission: 'access content'
This tells Drupal's router: "when someone requests /hello, and they have the access content permission, hand the request to HelloWorldPage." No route, no page — this file is what turns a URL into a page at all.
Step 4 — write the controller
Create src/Controller/HelloWorldPage.php (the path under src/ must mirror the class's namespace, per PSR-4):
<?php
declare(strict_types=1);
namespace Drupal\hello_world\Controller;
use Drupal\Core\Controller\ControllerBase;
/**
* Returns responses for Hello World routes.
*/
final class HelloWorldPage extends ControllerBase {
/**
* Builds the response.
*/
public function __invoke(): array {
$build['content'] = [
'#type' => 'item',
'#markup' => $this->t('It works!'),
];
return $build;
}
}
Notice this returns a render array, not a raw HTML string — '#markup' => $this->t('It works!') is Drupal's way of saying "here's a translatable string of content," and Drupal's rendering system takes care of turning it into a full HTML page around your site's theme.
Step 5 — enable and verify
- Visit
/admin/modules, search "Hello World," check the box, and click Install — or from the command line:drush en hello_world -y. - If anything looks stale, clear the cache:
drush cr.
Now visit /hello — you should see "It works!" on the page.
drush cr and try again before suspecting a typo in the YAML.Quick check: why does the controller return an array like
['#type' => 'item', '#markup' => ...]instead of justreturn "It works!";? (Answer: Drupal's rendering pipeline needs a render array so it can apply theming, caching metadata, and let other modules alter the output before it becomes final HTML — a raw string skips all of that machinery.)
Key takeaways
- The only file a module truly requires is a valid
*.info.yml— everything else exists only because you need that specific capability. - A module's machine name (its folder name) shows up everywhere: namespaces, hook function prefixes, service IDs — choose it carefully and keep it lowercase with underscores.
*.routing.ymlmaps a URL path to a controller; the controller's job is to return a render array, never raw HTML.- Whenever a new route or hook doesn't seem to be taking effect, clear the cache (
drush cr) before assuming your code is wrong.
Coming up next
You've seen the .info.yml file in passing — next, we slow down and go through every key it supports, including the ones this lesson didn't need, in Module Info.yml File.