Drupal 11.4 URLs: use core APIs instead of joining strings
Drupal 8 arrived in 2015, yet Drupal code still contains URLs assembled by joining strings instead of using the tools in core. Those shortcuts still cause problems in Drupal 11.4.
A link built as '/node/' . $id . '?source=' . $source
can look correct until the site uses an alias, a language prefix, or a
query value containing &. Drupal already has APIs for
those cases. In this article, I’ll show how to choose between
Url::fromRoute(), Url::fromUri(), and
Url::fromUserInput(), then render links without dropping
cache metadata.
The examples target Drupal 11.4 and were checked against the Drupal 11.4.9 release of core. They are fragments for custom modules running inside a bootstrapped Drupal application.
In this article:- Which URL API should you use?
- Generate links from routes and entities
- Handle external URIs and user-entered paths
- Pass query parameters, fragments, and language options
- Render links and check access
- Keep cache metadata when a string is required
- What should your team test?
- Need help with custom Drupal code?
Which URL API should you use?
Start with what your code knows about the destination. If it knows a route name, pass that name to Drupal rather than reconstructing the route’s current path. If it already has an entity, use the entity’s link template.
| What you have | Use | Example |
|---|---|---|
| A Drupal route and its parameters | Url::fromRoute() |
entity.node.canonical with a node
parameter |
| A loaded entity | $entity->toUrl() |
A node’s canonical page |
| An external URI | Url::fromUri() |
https://example.com/docs |
| A known non-routed local resource | Url::fromUri() with base: |
base:robots.txt |
| A relative path entered by an editor | Url::fromUserInput() |
/contact, ?page=2, or
#details |
Link text and a Url object |
Link::fromTextAndUrl()->toRenderable() |
A link in a render array |
A URL object represents a destination. It does not, by itself, approve that destination or grant access to it. That distinction matters for links to administrative pages and for values coming from users.
Generate links from routes and entities
Consider this manually assembled path:
// Avoid building a known Drupal route as a string.
$path = '/node/' . $node_id;
It hardcodes the path structure and bypasses Drupal’s URL generation. Replace it with the route name and parameters:
use Drupal\Core\Url;
// Example: node 42 exists on the site.
$url = Url::fromRoute('entity.node.canonical', ['node' => 42]);
Drupal can then apply the site’s outbound path processing, including
configured aliases and language prefixes. The resulting path is
configuration-dependent: do not assume that node 42 always becomes
/node/42 or that a language prefix is always present.
If you have already loaded a node, ask the entity for its canonical URL:
// $node is a loaded Drupal\node\NodeInterface instance.
$url = $node->toUrl('canonical');
For other entity types, check which link templates they provide. Not every entity has a canonical page, and generating a URL is not a substitute for checking whether an entity exists or whether the current user can view it.
In Twig, a known route can be linked with path():
<a href="{{ path('entity.node.canonical', {'node': node.id}) }}">
{{ 'Read more'|t }}
</a>
This assumes that the template receives a node and that the
surrounding component handles its access and cacheability requirements.
Use Twig’s url() function when you need an absolute route
URL. Neither function makes a destination accessible to a user who lacks
permission.
Handle external URIs and user-entered paths
Url::fromUri() is appropriate when your destination is a
URI rather than a route name:
use Drupal\Core\Url;
$documentation = Url::fromUri('https://example.com/docs');
$email = Url::fromUri('mailto:[email protected]');
$robots = Url::fromUri('base:robots.txt');
The base: scheme builds a URL relative to the Drupal
installation’s base path. It is intended for resources you know are not
handled by Drupal’s routing system. It does not check that the file
exists.
Do not pass /node/42 directly to fromUri()
as if it were an absolute URI: an ordinary relative path has no scheme.
For an editor-entered relative reference, use
fromUserInput() instead:
use Drupal\Core\Url;
$contact = Url::fromUserInput('/contact');
$next_page = Url::fromUserInput('?page=2');
$section = Url::fromUserInput('#details');
This method requires a value beginning with /,
?, or #. It can resolve a local path through
Drupal’s routing and path processing when a matching route exists, but
it also accepts local paths that do not correspond to a route.
It is not a broken-link checker. Handle invalid input
in your form validation rather than letting an exception reach the
visitor.
For external destinations supplied by users, apply the field’s
intended rules before constructing the URL. A field limited to web links
should explicitly allow the intended schemes, typically
http and https; a redirect workflow may also
need a host allowlist. fromUri() is a constructor, not a
general-purpose validator for redirects, remote requests, or business
rules.
Pass query parameters, fragments, and language options
Appending '?q=' . $value creates ambiguity when the
value contains spaces, ampersands, or other reserved characters. Pass
unencoded values in the query option and let Drupal encode
them:
use Drupal\Core\Url;
$url = Url::fromRoute('entity.node.canonical', ['node' => 42], [
'query' => [
'search' => 'R&D / Europe',
'page' => 2,
],
'fragment' => 'technical-details',
'absolute' => TRUE,
]);
The query above illustrates URL construction; a node page does not automatically implement search or pagination just because these parameters are present. A fragment also needs a matching HTML element ID on the destination page.
Do not URL-encode the query values first, and do not include
# in the fragment option. Drupal’s
UrlHelper::buildQuery() is useful when a separate
integration genuinely needs only a query string. For a Drupal URL,
prefer the query option over assembling that string
yourself.
The language option accepts a language object, not a
language code string. In a service with an injected language manager,
that might look like this:
$language = $this->languageManager->getLanguage('pl');
if ($language === NULL) {
throw new \InvalidArgumentException('Polish is not configured.');
}
$url = $node->toUrl('canonical', ['language' => $language]);
This example assumes $node is loaded and the service has
the language_manager service injected as
$this->languageManager, typed as
Drupal\Core\Language\LanguageManagerInterface. URL language
selection does not create a missing content translation. Also check the
site’s language negotiation and aliases before expecting a particular
output.
For links used in emails or feeds, absolute => TRUE
avoids a relative destination. In command-line jobs, make sure the
application has the correct site URI context; requesting an absolute URL
does not invent the intended production hostname.
Render links and check access
When a link will appear on a Drupal page, keep it in the render
system instead of concatenating an <a> tag:
use Drupal\Core\Link;
use Drupal\Core\Url;
$url = Url::fromRoute('user.login');
$build['login'] = Link::fromTextAndUrl(t('Sign in'), $url)
->toRenderable();
Return $build from the relevant build callback. In an
object-oriented class, use its injected translation service or
translation trait rather than introducing additional global service
calls. Here, t() keeps the procedural example short.
The equivalent render array is:
$build['login'] = [
'#type' => 'link',
'#title' => t('Sign in'),
'#url' => $url,
];
A link render element handles link generation and associated bubbleable metadata. However, it does not automatically perform the route access check you need for conditional visibility. If the link should only appear when the current user can access the destination, add an access result:
$build['login']['#access'] = $url->access(NULL, TRUE);
The second argument requests an access-result object, including its
cacheability information, rather than only a boolean. Passing that
object through #access lets Drupal account for the result
when caching rendered output.
This check is meaningful for routed URLs. For an unrouted URI,
Url::access() returns an allowed result; it does not verify
a remote server’s permissions. The destination route must still enforce
its own access rules when requested, regardless of whether a link is
visible.
Keep cache metadata when a string is required
Some integrations require a plain URL string.
Url::toString() produces one, but its default return value
does not carry cache metadata. If that string becomes part of cached
Drupal output, collect and propagate the metadata explicitly:
use Drupal\Core\Url;
$url = Url::fromRoute('entity.node.canonical', ['node' => 42]);
$generated = $url->toString(TRUE);
$build = [
'#type' => 'container',
'address' => [
'#plain_text' => $generated->getGeneratedUrl(),
],
];
$generated->applyTo($build);
toString(TRUE) returns a GeneratedUrl
object. The example uses it to display an address as plain text, while
preserving the metadata collected during URL generation. In a more
complex build, merge it with the component’s existing cacheability
information rather than replacing that information.
This covers metadata from URL generation, not every dependency of the
surrounding component. If the output also depends on an entity label,
access result, or configuration object, include those dependencies too.
For a normal clickable link, toRenderable() remains the
simpler choice.
Do not confuse Url::toString() with
Link::toString(). The first generates a URL; the second
generates link markup. Core’s Link documentation recommends
toRenderable() for HTML rendering so cache information
stays in Drupal’s render pipeline.
What should your team test?
The right API removes manual string assembly, but a review still needs to cover the site’s configuration. I would check these cases before merging a change:
- Aliases and languages: a node with a path alias, a second URL language, and a missing translation.
- Query values: spaces,
&, non-ASCII characters, and values that would reveal double encoding. - Installation context: a site served from a subdirectory and absolute links generated by a command-line job.
- Access and caching: users with different permissions, including a warm render cache after switching users.
- User input: valid local references, a missing destination, and values rejected by the field’s scheme or host rules.
- Markup: link labels containing characters such as
<or&, without bypassing escaping through raw markup.
Use unit tests for your own input rules and functional tests for generated links, aliases, access, and rendered output. Do not assert one fixed path when the behavior under test is supposed to honor configurable aliases.
The team rule is short: routes for known Drupal pages, entity link templates for loaded entities, URIs for non-route destinations, and render arrays for page links. Converting to a string should be a deliberate boundary, not the default way to build HTML.
Read also: Combining strings and variables in PHP: examples and common mistakes.
Need help with custom Drupal code?
If your modules contain hardcoded paths, inconsistent access checks, or cached links that behave differently between languages, those areas deserve a focused code review. Droptica’s Drupal development team can help refactor custom modules and verify their behavior on your site. Contact us to discuss the code you want to improve.