Build a custom Drupal route and controller
A custom Drupal route gives a site a predictable URL and a controlled way to reach application code. Instead of tying a page to a content type or a contributed module, you define the path, access rules and callback in Drupal’s routing system. A controller then receives the matched request and produces a response that fits the site’s theme and component library.
The controller is the PHP side of that arrangement. It can read configuration, query entities, call services, validate input and build a render array. Keeping URL metadata in YAML and request handling in PHP makes the feature easier for editors, developers and automated tests to maintain.
This walkthrough uses Drupal 10 or 11 and a custom module named custom_feature. It creates a permission-protected status page, then shows how to add a parameterised route, a form and an internal link. The examples use Australian details where they matter: a .com.au site, a team spread across Sydney, Melbourne and Brisbane, and a late-afternoon arvo deployment window.
Plan the route and controller
Start with the user journey rather than the class name. Decide which URL should be requested, which roles may use it, whether it should accept GET, POST or both, and what the page must display. Protect operational data with a specific permission rather than merely hiding it from the menu.
Separate the feature into three responsibilities. The route matches a path and applies requirements. The controller coordinates the response. A service holds reusable work, such as calculating health information or calling an external API. This split prevents a controller from becoming a container for every rule in the module.
Use a namespaced route name such as custom_feature.status; route names are global, so an editor-friendly path can still have a stable technical identifier. Choose a path that will not compete with core or contributed routes. For an Australian organisation’s .com.au domain, keep the hostname out of the controller.
Define the route with YAML
Create custom_feature/custom_feature.routing.yml. This definition exposes /custom/status to permitted users and points it at StatusController::page:
custom_feature.status:
path: '/custom/status'
defaults:
_controller: '\Drupal\custom_feature\Controller\StatusController::page'
_title: 'Site status'
requirements:
_permission: 'access custom status'
methods: [GET]
defaults identifies the callback and supplies a page title. requirements runs before the controller and blocks users who lack the permission. The GET restriction prevents the route from accepting accidental form submissions. For an administration page, add _admin_route: TRUE under options and use an appropriate admin path.
The referenced permission must exist in custom_feature/custom_feature.permissions.yml:
access custom status:
title: 'View the custom status page'
description: 'Access the status page provided by the Custom Feature module.'
After enabling the module, rebuild Drupal’s caches so the router and permission data are discovered. The module machine name, route name and controller namespace must agree exactly; Drupal treats these values as code identifiers, including letter case.
Build the controller and inject services
Place the class at custom_feature/src/Controller/StatusController.php. This example uses dependency injection to read the site name from configuration:
<?php
namespace Drupal\custom_feature\Controller;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
class StatusController extends ControllerBase {
protected ConfigFactoryInterface $configFactory;
public static function create(ContainerInterface $container): static {
$instance = new static();
$instance->configFactory = $container->get('config.factory');
return $instance;
}
public function page(): array {
$site_name = $this->configFactory->get('system.site')->get('name');
return [
'#type' => 'container',
'#attributes' => ['class' => ['custom-feature-status']],
'site_name' => [
'#type' => 'heading',
'#level' => 2,
'#text' => $site_name,
],
'message' => [
'#type' => 'text',
'#text' => $this->t('The custom route is responding normally.'),
],
];
}
}
The create() factory is called by Drupal’s service container. The controller requests config.factory, and the container supplies the configured implementation. This is easier to test and replace than creating the dependency with new. Because this route has no parameters, page() needs no arguments.
Returning a render array lets Drupal’s rendering system apply the active theme, libraries and cache metadata. A controller may also return a Response for an API or file download, but a themed page should normally use render elements. Keep presentation details in the render array and URL rules in the route definition.
Connect routes through Drupal APIs
A hard-coded link such as /custom/status can break when a site moves from local development to staging or a client’s .com.au production domain. Generate links from route names instead:
use Drupal\Core\Link;
use Drupal\Core\Url;
$build['status_link'] = Link::createFromRoute(
$this->t('Site status'),
'custom_feature.status'
)->toRenderable();
$status_url = Url::fromRoute(
'custom_feature.status',
[],
['absolute' => TRUE]
);
Link::createFromRoute() is useful for page content and menus. Url::fromRoute() is useful when another controller needs the URL, a redirect needs a destination or an email template needs an absolute link. Pass route parameters in the second argument rather than appending them by hand.
A clean separation keeps each concern in the right layer:
| Concern | Route definition | Controller | Service |
|---|---|---|---|
| Main purpose | Match a URL and apply request rules | Coordinate the response | Encapsulate reusable operations |
| Typical location | *.routing.yml |
src/Controller/ |
src/Service/ |
| Common inputs | Path, defaults, requirements, methods | Route arguments and services | Configuration, entities and APIs |
| Typical output | A matched callback | A render array or response | A reusable result or exception |
If two controllers need the same operation, move it into a service and register that service in custom_feature.services.yml. The route still identifies the callback, the controller still handles the request, and the service performs shared work without coupling either layer to the other.
Handle parameters and form submissions
A fixed path is only one option. For a request form, point a route at a Drupal Form API class instead of reading $_POST directly:
custom_feature.insight_request:
path: '/insights/request'
defaults:
_form: '\Drupal\custom_feature\Form\InsightRequestForm'
requirements:
_permission: 'submit insight request'
methods: [GET, POST]
The form class supplies field definitions, validation, cross-site request forgery protection and the redirect after submission. Keeping it in src/Form/ follows Drupal’s conventions and gives automated tests a clear target.
If the form collects names, email addresses or other personal information, the Australian context adds operational duties. The site owner should check the Privacy Act 1988 and, for commercial email, the Spam Act 2003. Consent records, retention periods, secure hosting and a clear contact address matter as much as the PHP code. These requirements belong in the project scope for an Australian organisation, rather than becoming a launch-day surprise.
Dynamic paths can pass a validated value to the controller:
custom_feature.insight:
path: '/insights/{slug}'
defaults:
_controller: '\Drupal\custom_feature\Controller\InsightController::view'
requirements:
_permission: 'access custom insights'
slug: '[a-z0-9-]+'
InsightController::view(string $slug) receives the matched value by argument name, while the regular expression rejects unexpected characters before the method runs. If the parameter represents a content entity, use Drupal’s entity parameter converter and enforce entity view access instead of loading an unverified identifier. Define /insights/request before /insights/{slug} so the fixed form path cannot be mistaken for a slug.
Test routes and diagnose failures
A quick arvo sanity check should cover the router, permissions and rendered output. First confirm that the URL resolves to the intended callback, then test the page as both a visitor and an authorised editor. Caching can hide a successful code change, so test against a rebuilt container and the same cache behaviour expected in production.
Use these request checks:
- Run
php bin/drupal router:debug /custom/statusand confirm the route name, controller and HTTP method. - Rebuild the container and router with
php bin/drupal cache:rebuildafter changing YAML or service definitions. - Test an anonymous browser session and a signed-in account with the required role.
- Compare the HTTP response with the web server’s access log and Drupal’s recent log messages.
When the page fails, match the symptom to the likely layer:
- A missing route usually means the module is disabled, the YAML filename is wrong or the router cache is stale.
- A missing class or method points to a namespace, directory or capitalisation mismatch.
- An access-denied response usually means the permission, route requirement or user role needs checking.
- Broken markup often comes from a malformed render array rather than the route definition.
Check the response headers as well as the page body. A redirect to the login page can confirm that access control works, while a 404 can indicate that Drupal never matched the route. On shared Australian hosting, the application log may sit outside the project directory, so establish its location before troubleshooting through the wrong hosting panel.
Deploy and maintain the custom route
Treat the module code and site configuration as separate deployment artefacts. Commit the module to version control, run composer install --no-dev --optimize-autoloader for the target release, deploy configuration such as permissions and views, enable the module, and rebuild caches. Run the router check against the deployed URL before declaring the change complete.
Local hosting choices matter in the Australian market. A provider with Sydney or Melbourne data residency can suit organisations with specific governance expectations, while AUD billing and support hours affect which maintenance window works for an agency. A team distributed across Brisbane, Sydney and Melbourne can use an agreed arvo release window, then verify the route from each location before the next regional handover.
After launch, keep the route name stable, document its permission and add a smoke test for the path and access rule. If the site changes domains or moves behind a new HTTPS hostname, update Drupal’s base URL and trusted host patterns rather than editing links throughout the controller. Versioned code and configuration provide a practical rollback path when a route conflicts with a contributed module.
That release sequence gives the URL a clear owner, the controller a defined role and the page a repeatable access check. The same pattern supports a status screen, public lookup or editorial tool without coupling the feature to a particular theme, hostname or Australian deployment provider.