Stop Hardcoding Links in Contao: Insert Tags Are the Engineering Decision You Already Have
Hardcoded URLs in Contao content break silently when aliases change. Insert tags store references instead of literals — here's the engineering case, the caching catch, and a custom tag pattern.
09 Feb 2026, 12:17 UTC

An editor renames a page alias from about-us to ueber-uns. Nothing breaks visibly. Three weeks later someone notices that every "Learn more about us" link across forty news articles now returns a 404. The fix is a tedious database find-and-replace — or, better, a decision made before the content was written: never paste literal URLs, dates, or user data into Contao content when an insert tag can produce the value at render time.
This post makes the case for insert tags as a deliberate engineering choice, shows the caching trade-off that trips people up, and walks through a custom tag you can actually test.
What insert tags actually do
Insert tags are small placeholders — {{page::title}}, {{date::Y}}, {{link_url::15}}, {{user::email}} — that editors and developers can drop into articles, news items, and many templates. Contao replaces them when the page is rendered. The important property is indirection: the tag stores a reference (page ID 15), not a value (/about-us.html).
That indirection is what survives change. Page aliases get renamed, domains change at launch, layouts get rebuilt, a company phone number gets updated. If the content holds references instead of literals, none of those events require touching content.
A worked example: the link that doesn't break
Compare two ways of writing the same internal link in a rich-text element:
<!-- Fragile: baked-in literal -->
<a href="/about-us.html">About us</a>
<!-- Resilient: resolved at render time -->
<a href="{{link_url::15}}">About us</a>With the second form, renaming page 15's alias updates every rendered link automatically, because Contao resolves the ID to the current alias on each render. You can verify this in a few minutes: create two test pages, put {{link_url::ID-of-page-two}} in an article on page one, then rename page two's alias and reload. The link target should follow the new alias with no content edit.
The same reasoning applies elsewhere: {{date::Y}} in a copyright line instead of a hardcoded year, {{page::title}} in a template instead of duplicating the page name, and custom tags (below) for anything project-specific.
The cache is where this gets interesting — and dangerous
Contao's page cache complicates the story, and this is the part worth understanding before you roll insert tags out broadly. Some tags are cache-aware: {{date::*}}, for example, is replaced on every request even when the page itself is served from cache. Others are baked into the cached output once and reused for every visitor.
That distinction matters most for personalization. If a tag like {{user::*}} ends up rendered into a cached page, visitor A's data can be served to visitor B. The safe habits:
- Treat any user-specific tag as a caching risk until proven otherwise. Check the response cache headers and reload as two different users (or logged in vs. anonymous) to confirm nothing leaks.
- Test dynamic behavior directly: enable the page cache, put
{{date::H:i:s}}in an article, reload twice. If the timestamp changes, replacement is happening per-request; if it's frozen, it's baked into the cache. - Exact behavior differs between Contao 4.13 LTS and 5.x — verify against the version you actually run, not the documentation you found first.
Custom insert tags: one source of truth for project data
The strongest use case is project-specific data that appears in many places: a support phone number, a booking URL, a legal entity name. Contao lets you register custom insert tags via the replaceInsertTags hook. Because Contao 4/5 runs on Symfony, the handler can live in a normal service class — dependency-injected and unit-testable — rather than in legacy config files.
Conceptually, the service checks whether the tag matches your prefix and returns the replacement value, or returns false to let other handlers try:
// src/EventListener/ReplaceInsertTagsListener.php (sketch — check hook registration for your Contao version)
public function __invoke(string $tag): string|false
{
if ($tag === 'company::phone') {
return $this->config->getSupportPhone();
}
return false; // not ours — let other handlers run
}Now {{company::phone}} works in every content element that parses insert tags, and changing the number is a config change, not a content migration. Before relying on this pattern, check your Contao and Symfony versions in composer.lock; hook registration mechanics have changed across major versions.
The trade-off: editor fragility
Insert tags are not free. A typo'd tag doesn't raise an error — it renders as literal text on the page, so {{link_url:15}} (one colon) silently becomes visible garbage. Non-technical editors can find a rich-text field full of {{...}} snippets intimidating and easy to damage. And not every backend field parses insert tags; some strip or ignore them, so a tag that works in a text element may do nothing in another field type.
The pragmatic split: use insert tags liberally in templates and developer-controlled content, use them for links and dates in editor content with a short documented cheat sheet, and prefer purpose-built content elements or backend modules over asking editors to memorize tag syntax.
What to do Monday morning
Audit one high-traffic section of your site for hardcoded internal URLs and replace them with {{link_url::ID}}. Add a cache test with {{date::H:i:s}} on a staging copy. If you have repeated project data, wrap it in one custom tag. Small moves, but they convert the next alias rename from an incident into a non-event.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.