Drupal Routing Explained: Mapping URLs to Controllers with routing.ymlfor Drupal 11 , and 10

Last updated :  

In the last lesson, you learned how a single YAML file — .info.yml — makes Drupal aware that your module exists at all. But existing isn't the same as being reachable. If someone types a URL into their browser and hits enter, how does Drupal know what to show them? That's what this lesson answers, and it's the single most important mechanic you'll learn in your first week of Drupal development: routing.

What is routing, really?

Think of routing as a switchboard operator. Every request that hits your Drupal site — every URL a visitor's browser asks for — passes through this switchboard first. The switchboard's only job is to look at the path (the part of the URL after your domain name, like /examples/page-example) and connect it to the right piece of PHP code to handle it. Without a route, Drupal has no idea what to do with a URL, and the visitor gets a 404 "Page not found" error.

In Drupal, you define that switchboard's connections declaratively, in YAML, inside a file named [module_name].routing.yml. No routing file, no pages — it's as fundamental as the .info.yml file you met last time.

What you'll learn in this lesson

  • How a route connects a URL path to a PHP controller method
  • How Drupal enforces access control on a route before your code ever runs
  • How to capture dynamic pieces of a URL (like an ID number) as parameters
  • Why routing, menus, and permissions live in three separate files instead of one
Quick refresher: we're still working inside the page_example module from the Drupal Examples project — the same one whose .info.yml file you read in the last lesson. Everything here is real, shipped code, not a simplified example written just for teaching.

The source file

Path: modules/page_example/page_example.routing.yml

# In order to to create pages it is necessary to define routes for them. A route
# maps a URL path to a controller. It defines with what function or method will
# be called when a URL is accessed. The following lines defines three of them
# for this module.
# Menu items corresponding to these URLs are defined separately in the
# page_example.links.menu.yml file.
# If the user accesses http://example.com/?q=examples/page-example, the routing
# system will look for a route with that path. In this case it will find a
# match, and execute the _controller callback. In this case the callback is
# defined as a classname
# ("\Drupal\page_example\Controller\PageExampleController") and a method
# ("description").

# Access to this path is not restricted. This is notated as _access: 'TRUE'.
page_example.description:
  path: '/examples/page-example'
  defaults:
    _controller: '\Drupal\page_example\Controller\PageExampleController::description'
    _title: 'Page Example'
  requirements:
    _permission: 'access content'

# If the user accesses http://example.com/?q=examples/page-example/simple,
# the routing system will look for a route with that path. In this case it will
# find a match, and execute the _controller callback. Access to this path
# requires "access simple page" permission.
page_example.simple:
  path: '/examples/page-example/simple'
  defaults:
    _controller: '\Drupal\page_example\Controller\PageExampleController::simple'
    _title: 'Simple - no arguments'
  requirements:
    _permission: 'access simple page'

# If the user accesses
# http://example.com/?q=examples/page-example/arguments/1/2, the routing system
# will first look for examples/page-example/arguments/1/2. Not finding a match,
# it will look for examples/page-example/arguments/1/{*}. Again not finding a
# match, it will look for examples/page-example/arguments/{*}/2. Yet again not
# finding a match, it will look for examples/page-example/arguments/{*}/{*}.
# This time it finds a match, and so it will execute the _controller callback.
# In this case, it's PageExampleController::arguments().
# Since the parameters are passed to the function after the match, the function
# can do additional checking or make use of them before executing the callback
# function. The placeholder names "first" and "second" are arbitrary but must
# match the variable names in the callback method, e.g. "$first" and "$second".
page_example.arguments:
  path: '/examples/page-example/arguments/{first}/{second}'
  defaults:
    _controller: '\Drupal\page_example\Controller\PageExampleController::arguments'
  requirements:
    _permission: 'access arguments page'

How it works

The route name

Every route starts with a top-level YAML key that acts as its unique machine name — page_example.description, for instance. The convention is always [module_name].[route_identifier]. This isn't just a label for humans: Drupal uses this exact string all over its API whenever code needs to point at a route without hardcoding a URL — for example Url::fromRoute('page_example.description'). Route names must be unique across every enabled module on the entire site, which is exactly why prefixing with your module's machine name matters.

path: the URL itself

path: '/examples/page-example'

This is the literal URL pattern that has to match for this route to fire. It always starts with a leading slash. Visiting http://yoursite.com/examples/page-example matches this exact route.

Paths can also contain dynamic segments — placeholders wrapped in curly braces, like {first} and {second} in the third route below. Drupal's router tries to match the most specific literal path first, then progressively falls back to wildcard segments until something matches.

defaults_controller: what code actually runs

_controller: '\Drupal\page_example\Controller\PageExampleController::description'

This is the payoff of the whole route: a fully-qualified PHP class name, followed by :: and a method name. The leading backslash means "start from the very root of the PHP namespace tree, don't assume anything." When this route matches, Drupal instantiates PageExampleController (using its dependency-injection container if the class extends ControllerBase) and calls description() on it. That method is expected to hand back either a render array (which you'll meet properly in a later topic) or a raw HTTP Response object.

defaults_title: the page title

Sets the static text used both in the browser tab's <title> and the page's on-screen heading. Two of the three routes above set one directly ('Page Example' and 'Simple - no arguments'). The third, page_example.arguments, deliberately leaves it out — its controller method builds the title itself at runtime, since it depends on the URL parameters. For that situation you'd reach for _title_callback instead of a static _title.

requirements_permission: who's allowed in

This is Drupal's access-control gate, and it runs before your controller code ever executes — if the check fails, the visitor gets a 403 Access Denied page and your PHP never runs at all. _permission is the most common way to guard a route: its value must match a permission string, either one of Drupal core's built-ins or one your own module defines in a .permissions.yml file (which is exactly the topic of the next lesson).

  • page_example.description requires 'access content' — a broad core permission most visitors already have.
  • page_example.simple requires 'access simple page' — a custom permission this module defines for itself.
  • page_example.arguments requires 'access arguments page' — another custom permission, specific to that one route.

Other requirement keys you'll encounter as you go deeper: _role (require a specific user role), _access: 'TRUE' (fully public, no check at all), _entity_access (delegate to an entity's own access rules), and _custom_access (write your own arbitrary access-check method).

Capturing URL parameters

path: '/examples/page-example/arguments/{first}/{second}'

The names inside the curly braces — first and second — are arbitrary, but they are not decorative: they must match, character for character, the parameter names in the controller method's signature. When a visitor requests /examples/page-example/arguments/5/10, Drupal extracts 5 and 10 from the URL and calls PageExampleController::arguments($first, $second) with those values already filled in. No manual parsing of the request object required — it's a completely declarative pipeline from URL to typed method arguments.

Why this matters: this pattern — named placeholders that map straight onto method parameters — is used everywhere in Drupal, from custom routes like this one to core entity routes like /node/{node}. Get comfortable with it now and a lot of "magic" elsewhere in Drupal will stop feeling magic.

Why routing, menus, and permissions live in separate files

Notice that this file only ever decides which code runs for which URL, and who's allowed to trigger it. It says nothing about whether a link to that URL shows up in a menu, and the permission strings it references ('access simple page', 'access arguments page') are only used here — they're actually declared somewhere else, in page_example.permissions.yml. Likewise, navigation entries pointing at these routes live in page_example.links.menu.yml.

This is a deliberate Drupal convention, not an accident: each concern — URL resolution, access control, navigation — gets its own file, so you can reason about (and change) one without wading through the other two.

See it for yourself

Visit /examples/page-example on your own DDEV site. Everything you see on that page — its existence, its title, the fact that it loaded at all instead of 404ing — traces directly back to the page_example.description route you just read.

The Page Example page rendered live at /examples/page-example

Quick check: if you wanted this same page reachable at /examples/page-example/simple too, but handled by a different method, would you edit this route — or add a new one? (Answer: a new one. Each route maps exactly one path pattern to one controller method; that's why the file above defines three separate routes rather than branching inside one.)

Key takeaways

  • A route lives in [module_name].routing.yml and maps a URL path to a controller class and method via _controller under defaults.
  • Route machine names follow [module_name].[route_identifier] and are how the rest of Drupal's API refers to a route without hardcoding its URL — e.g. Url::fromRoute().
  • requirements controls access, and it runs before your controller code executes at all. _permission is the most common check, and its value must match a permission declared in a .permissions.yml file (or a core permission).
  • URL parameters are written as {placeholder} segments in path and arrive as method arguments with matching names — the placeholder and the PHP parameter name must be identical.
  • A static title comes from _title; a title that depends on the URL's parameters needs _title_callback instead.
  • Routing, menu links, and permissions are intentionally three separate files — don't be tempted to merge them, even in your own modules.

Coming up next

You just saw three routes reference permission strings like 'access simple page' — but where do those strings actually come from, and who decides which roles get them? That's next: page_example.permissions.yml, and how Drupal turns a plain-text permission name into a real, enforceable checkbox in the site's permissions admin page.