
Contao 5 Twig Templates: Frontend-Ausgabe mit Fragment Controllern

Der Umstieg auf Contao 5 Twig Templates ist der radikalste, aber auch lohnendste Schritt in der modernen Contao-Entwicklung. Während wir früher mit unsicheren PHP-Templates (.html5) und globalen Arrays kämpften, bietet die neue Architektur Sicherheit und Struktur. Wer Contao 5 Twig Templates beherrscht, schreibt keinen Spaghetti-Code mehr, sondern nutzt echte Fragment Controller und die mächtige Vererbung der Twig-Engine.
Unsere Datenbank ist gefüllt, die Logik ist getestet (siehe Teil 6). Aber wenn wir die Webseite aufrufen, sehen wir noch nichts. Es ist Zeit, die "Black Box" zu öffnen. Wir rendern jetzt unsere Strandkörbe.
Warum Twig statt PHP? Laut der offiziellen Contao Developer Documentation und dem Upgrade Guide gibt es drei Gründe für den Wechsel:
Security by Design: Contao 5 Twig Templates escapen Ausgaben automatisch. XSS-Lücken (Cross-Site Scripting) gehören der Vergangenheit an.
Vererbung (Inheritance): Wir müssen CSS-Klassen oder Wrapper-Divs nicht neu erfinden, sondern erben via
{% extends %}direkt vom Core.Debugging: Twig warnt dich sofort, wenn du eine Variable nutzt, die nicht existiert.
Das ist der Plan:
Einen Content Element Controller schreiben (Datenbeschaffung).
Das Twig Template erstellen (Vererbung nutzen).
Bilder professionell mit dem Image Studio (
contao_figure) rendern.Listenansichten und Attribute-Helper (
attrs) meistern.
Lass uns den Controller bauen.

1. Der Fragment Controller: Datenbeschaffung
In Contao 4 nutzten wir oft noch Klassen, die von ContentElement erbten und die generate()-Methode überschrieben. Das war "okay", aber nicht wirklich Symfony-like. In Contao 5 ist ein Content-Element (oder Modul) technisch gesehen ein Fragment Controller. Er nimmt eine Anfrage entgegen, verarbeitet Daten und gibt eine Response zurück. Das macht den Code extrem testbar und sauber.
Wir erstellen einen Controller, der einen spezifischen Strandkorb basierend auf einer Auswahl im Backend anzeigt. Wir gehen davon aus, dass wir im DCA (Teil 3) ein Feld beachChair angelegt haben, das die ID des Strandkorbs speichert.
Die Datei: Erstelle: /src/BeachsideBundle/src/Controller/ContentElement/BeachChairElementController.php
1<?php
2
3namespace Acme\BeachsideBundle\Controller\ContentElement;
4
5use Acme\BeachsideBundle\Repository\BeachChairRepository;
6use Contao\ContentModel;
7use Contao\CoreBundle\Controller\ContentElement\AbstractContentElementController;
8use Contao\CoreBundle\DependencyInjection\Attribute\AsContentElement;
9use Contao\CoreBundle\Twig\FragmentTemplate;
10use Symfony\Component\HttpFoundation\Request;
11use Symfony\Component\HttpFoundation\Response;
12
13#[AsContentElement(category: 'beachside', template: 'content_element/beach_chair_details')]
14class BeachChairElementController extends AbstractContentElementController
15{
16 public function __construct(
17 private readonly BeachChairRepository $repository
18 ) {
19 }
20
21 protected function getResponse(FragmentTemplate $template, ContentModel $model, Request $request): Response
22 {
23 // Wir gehen davon aus, dass wir im DCA ein Feld 'beachChair' haben,
24 // das die ID speichert (haben wir in Teil 4 vorbereitet).
25 $chairId = $model->beachChair;
26
27 if (!$chairId) {
28 return new Response(); // Leeres Element, wenn nichts ausgewählt
29 }
30
31 // Daten aus der DB holen
32 $chair = $this->repository->find($chairId);
33
34 if (!$chair) {
35 // Optional: Fehlermeldung im Debug-Mode ausgeben
36 return new Response();
37 }
38
39 // Daten an das Template übergeben
40 $template->set('chair', $chair);
41
42 // Zusatz-Feature: Verfügbarkeit prüfen (Nutzen wir unseren Service aus Teil 5/6?)
43 // $template->set('isAvailable', ...);
44
45 return $template->getResponse();
46 }
47}Code-Analyse:
#[AsContentElement]: Dieses PHP 8 Attribut registriert den Controller automatisch bei Contao. Wir definieren hier auch gleich die Kategorie (für den Auswahl-Wizard im Backend) und den Pfad zum Twig-Template.Dependency Injection: Wir nutzen unser
BeachChairRepositorydirekt im Konstruktor. Keine statischen Aufrufe wieDatabase::getInstance()mehr! Das ist der moderne Weg.FragmentTemplate: Das ist unser Wrapper für das Twig-Template. Wir "befüllen" ihn mitset()und lassen ihn am Ende die fertigeResponsegenerieren.

2. Das Twig Template: Basics & Vererbung
Jetzt erstellen wir die Datei, die wir im Controller definiert haben: content_element/beach_chair_details. Der Speicherort hängt von deiner Bundle-Konfiguration ab, aber standardmäßig in einem lokalen Bundle liegt sie unter: /src/BeachsideBundle/templates/content_element/beach_chair_details.html.twig.
Der Clou bei Contao 5 Twig Templates: Wir fangen nicht bei Null an. Wir erben vom Basis-Element des Contao Cores. Das ist ein massiver Vorteil gegenüber alten .html5 Templates. Indem wir {% extends "@Contao/content_element/_base.html.twig" %} nutzen, kümmert sich Contao automatisch um:
Den umschließenden Wrapper (
<div>).Die CSS-ID und CSS-Klassen, die der Redakteur im Backend eingibt.
Den Zugriffsschutz (Element nur für Gäste/Mitglieder sichtbar).
Abstände (Space before/after).
Wir müssen uns nur um den Inhalt kümmern.
Der Code:
1{# Wir erben vom Basis-Template des Cores #}
2{% extends "@Contao/content_element/_base.html.twig" %}
3
4{# Wir überschreiben den 'content' Block #}
5{% block content %}
6
7 <div>
8 <header>
9 <h2>{{ chair.title }} <small>(Nr. {{ chair.chairNumber }})</small></h2>
10 </header>
11
12 <div>
13 <p>
14 Preise ab: <strong>{{ (chair.pricePerDay / 100)|format_currency('EUR') }}</strong> / Tag
15 </p>
16
17 <div>
18 {# 'raw' Filter ist sicher, da wir im DCA den TinyMCE nutzen, der Code bereinigt #}
19 {{ chair.description|raw }}
20 </div>
21 </div>
22
23 {# Platzhalter für Buchungs-Button #}
24 <div>
25 <button>Jetzt buchen</button>
26 </div>
27 </div>
28
29{% endblock %}Wichtige Twig-Features hier:
Getter-Support:
{{ chair.title }}funktioniert, obwohl die Propertyprivateist und die MethodegetTitle()heißt. Twig ist schlau genug, die Verknüpfung herzustellen.Filter:
|number_formatformatiert unseren Cent-Preis in lesbare Euro.extends: Wir schreiben keindivfür das Content-Element selbst. Das kommt aus der_base.html.twig.

3. Bilder rendern mit dem Image Studio
In früheren Contao-Versionen war der Umgang mit Bildern oft mühsam: FilesModel laden, Pfad suchen, Image::get() aufrufen. In Contao 5 nutzen wir das Image Studio und die mächtige Twig-Funktion contao_figure. Sie ist der neue Standard und generiert vollautomatisch responsive Bilder (srcset, sizes), WebP-Formate und Metadaten.
Voraussetzung: Wir gehen davon aus, dass deine Entity BeachChair ein Feld imageUUID hat (in der Datenbank als BINARY(16) gespeichert), das über den DCA-Filetree befüllt wurde.
Der Code im Template: Wir erweitern unser beach_chair_details.html.twig:
1{% block content %}
2 <div>
3
4 {# Bild rendern #}
5 {% if chair.image %}
6 <div>
7 {#
8 uuid: Die Binary UUID aus der Datenbank
9 size: Die ID einer Bildgröße aus dem Theme (z.B. 'product_image')
10 oder null für Originalgröße
11 #}
12 {{ contao_figure(chair.image, 'product_image', {
13 class: 'img-fluid rounded',
14 alt: chair.title
15 }) }}
16 </div>
17 {% endif %}
18
19 <header>...</header>
20 ...
21 </div>
22{% endblock %}Warum ist contao_figure genial? Laut der Image Studio Dokumentation passiert im Hintergrund Folgendes:
Metadaten-Lookup: Contao sucht automatisch den Alt-Text und die Bildunterschrift, die der Redakteur in der Dateiverwaltung hinterlegt hat. Du musst das nicht manuell tun!
Responsive Markup: Es wird ein komplettes HTML
<picture>-Element generiert, inklusive WebP-Sources und Fallbacks.Performance:
loading="lazy"undwidth/heightAttribute werden automatisch gesetzt, um Layout Shifts (CLS) zu verhindern.
Profi-Tipp: Wenn du eine Bildgröße "On-the-Fly" definieren willst (ohne Backend-Konfiguration), nutze die Funktion picture_config:
1{% set my_size = picture_config({
2 width: 800,
3 height: 600,
4 resizeMode: 'crop'
5}) %}
6
7{{ contao_figure(chair.imageUUID, my_size) }}
4. Listenansicht: Das Frontend Modul
Bisher haben wir ein einzelnes Element gerendert. Jetzt wollen wir eine Liste aller Strandkörbe anzeigen. Dafür eignet sich klassischerweise ein "Frontend Modul". Interessant in Contao 5: Die Grenze zwischen Content-Element und Modul verschwimmt. Beide sind Fragment Controller. Der Hauptunterschied liegt oft nur in der semantischen Nutzung (Module sind oft global im Layout eingebunden, Content-Elemente im Artikel).
Der Controller:
Erstelle: /src/BeachsideBundle/src/Controller/FrontendModule/BeachChairListController.php
1<?php
2
3namespace Acme\BeachsideBundle\Controller\FrontendModule;
4
5use Acme\BeachsideBundle\Repository\BeachChairRepository;
6use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
7use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
8use Contao\CoreBundle\Twig\FragmentTemplate;
9use Contao\ModuleModel;
10use Symfony\Component\HttpFoundation\Request;
11use Symfony\Component\HttpFoundation\Response;
12
13#[AsFrontendModule(category: 'beachside', template: 'frontend_module/beach_chair_list')]
14class BeachChairListController extends AbstractFrontendModuleController
15{
16 public function __construct(
17 private readonly BeachChairRepository $repository
18 ) {
19 }
20
21 protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
22 {
23 // Alle Strandkörbe holen
24 // In einer echten App würden wir hier Paginierung und Sortierung einbauen.
25 $chairs = $this->repository->findAll();
26
27 $template->set('chairs', $chairs);
28
29 return $template->getResponse();
30 }
31}Das Template: Wir erstellen templates/frontend_module/beach_chair_list.html.twig. Hier nutzen wir die Stärke von Twig, um Code zu recyceln (DRY - Don't Repeat Yourself). Anstatt den HTML-Code für die "Strandkorb-Karte" neu zu schreiben, lagern wir ihn in ein Partial aus.
1{# Wir erben vom Basis-Modul-Template #}
2{% extends "@Contao/frontend_module/_base.html.twig" %}
3
4{% block content %}
5 <div>
6 {% for chair in chairs %}
7
8 {#
9 Wir inkludieren ein Teaser-Template und übergeben den aktuellen Strandkorb.
10 Das Partial '_teaser.html.twig' liegt im Ordner 'partial' oder 'common'.
11 #}
12 {% include '@AcmeBeachside/content_element/_beach_chair_teaser.html.twig' with { chair: chair } %}
13
14 {% else %}
15
16 {# Fallback, wenn das Array leer ist #}
17 <div>
18 Aktuell sind alle Strandkörbe im Winterschlaf.
19 </div>
20
21 {% endfor %}
22 </div>
23{% endblock %}Was wir gelernt haben:
AbstractFrontendModuleController: Das Gegenstück zum ContentElementController.{% for ... else %}: Twig Loops haben einen eingebautenelse-Zweig für leere Arrays. Das spart uns einif count > 0in PHP.include: Damit halten wir unsere Templates klein und wartbar.
Profi-Tipp: Nutze include oder Twig Components, um den Code für die "Karte" (Teaser) auszulagern. So kannst du das Layout für die Liste und das Einzel-Element teilen.
5. Attributes & HTML Helper: Schluss mit String-Chaos
Ein häufiges Problem in alten Templates war das Zusammenbauen von CSS-Klassen: $class = 'item ' . ($obj->highlight ? 'highlight ' : '') . $this->class; Das führt oft zu fehlenden Leerzeichen oder doppeltem Code.
In Contao 5 Twig Templates nutzen wir dafür die mächtige Funktion attrs(). Sie erstellt ein intelligentes Attribut-Objekt, das wir manipulieren können, bevor wir es rendern.
Beispiel: Dynamische Klassen für den Strandkorb Wir wollen dem Container eine Klasse geben, wenn der Strandkorb "Premium" ist (Preis > 20€), und eine Data-ID für JavaScript hinzufügen.
1{# Wir erstellen ein Attribut-Objekt #}
2{% set chairAttributes = attrs()
3 .addClass('beach-chair-card')
4 .addClass('is-premium', chair.pricePerDay > 2000) {# Bedingung direkt hier! #}
5 .set('data-id', chair.id)
6 .set('title', 'Strandkorb ' ~ chair.chairNumber)
7%}
8
9{#
10 Ausgabe:
11 data-id="42" title="Strandkorb 7"
12#}
13<div {{ chairAttributes }}>
14 ...
15</div>Warum ist das besser?
Sicherheit: Attribute werden automatisch escaped.
Logik-Trennung: Keine
if-Blöcke mitten im HTML-Tag.Merging: Wenn du ein Template erweiterst (
extends), kannst duattributes.mergeWith(...)nutzen, um die Klassen des Parent-Templates zu behalten und deine eigenen hinzuzufügen.
Weitere nützliche Helper für Contao 5 Twig Templates:
content_url(uuid): Generiert die URL zu einer Datei aus der Dateiverwaltung (anhand der UUID).format_date(timestamp, 'date'): Formatiert Datumswerte lokalisiert (benötigttwig/intl-extra).Beispiel:
{{ chair.tstamp|format_date('relative_medium') }}-> "vor 2 Tagen".
Verschachtelte Elemente (Nested Fragments): Wenn dein Strandkorb selbst Container für andere Inhaltselemente sein soll (z.B. ein Slider im Strandkorb-Detail), nutzt du: {{ content_element(nestedFragment) }}. Das ermöglicht komplexe Layout-Strukturen ("Elemente in Elementen"), die in Contao 5 viel sauberer gelöst sind als früher.

6. Performance: Caching verstehen
Contao 5 ist schnell. Aber nur, wenn wir das Caching verstehen. Standardmäßig cacht Contao die komplette HTML-Ausgabe deines Controllers automatisch, sofern die Seite im Seitenlayout als "cachebar" markiert ist und das Element nicht explizit als "privat" (nicht cachebar) definiert wurde.
Was aber, wenn wir eine individuelle Cache-Zeit für unseren Strandkorb wollen? Zum Beispiel soll die Verfügbarkeit für 1 Stunde (3600 Sekunden) im Cache bleiben, auch wenn die Seite eigentlich 24 Stunden gecacht wird.
Wo gehört der Code hin? Der Eingriff passiert in deinem Fragment Controller (z.B. BeachChairElementController.php), innerhalb der protected function getResponse.
Der exakte Code-Ablauf:
1// ... Imports und Klassendefinition ...
2
3 protected function getResponse(FragmentTemplate $template, ContentModel $model, Request $request): Response
4 {
5 // 1. Datenlogik (wie in Iteration 2)
6 $chairId = $model->beachChair;
7 $chair = $this->repository->find($chairId);
8
9 // Daten an das Template übergeben
10 $template->set('chair', $chair);
11
12 // -----------------------------------------------------------
13 // 2. Response Objekt generieren
14 // -----------------------------------------------------------
15 // Wir lassen das Template die Standard-Response erstellen.
16 $response = $template->getResponse();
17
18 // -----------------------------------------------------------
19 // 3. Cache-Header manipulieren (HIER IST DER ORT)
20 // -----------------------------------------------------------
21 // Wir setzen die "Shared Max Age" (TTL für Reverse Proxies/HttpCache).
22 // Das bedeutet: Dieses Fragment ist für 1 Stunde gültig.
23 if ($chair && $chair->isHighlyFluctuating()) {
24 // Kurze Cache-Zeit für dynamische Preise/Verfügbarkeit
25 $response->setSharedMaxAge(600); // 10 Minuten
26 } else {
27 // Lange Cache-Zeit für statische Inhalte
28 $response->setSharedMaxAge(3600); // 1 Stunde
29 }
30
31 // Optional: Varnish/Browser anweisen, neu zu validieren
32 // $response->headers->addCacheControlDirective('must-revalidate', true);
33
34 // -----------------------------------------------------------
35 // 4. Modifizierte Response zurückgeben
36 // -----------------------------------------------------------
37 return $response;
38 }Erklärung der Schritte:
Daten holen: Zuerst passiert die normale Logik.
$template->getResponse(): Das ist der entscheidende Moment. Contao wandelt das befüllte Template in ein HTTP-Response-Objekt um.Manipulation: Da
$responseein Objekt ist (Referenz), können wir jetzt Methoden darauf aufrufen.setSharedMaxAge()steuert den HTTP-Headers-maxage.Return: Erst jetzt geben wir das manipulierte Objekt an den Core zurück.
Szenario für Profis: ESI (Edge Side Includes) Wenn der Rest der Seite extrem lange gecacht werden soll (z.B. 1 Woche), aber der "Buchen"-Button live sein muss, lagerst du den Button in einen eigenen Controller aus und bindest ihn im Twig-Template via {{ render_esi(...) }} ein. Contao 5 erkennt das automatisch und cacht die Hauptseite und den Button getrennt voneinander.
Teil der Serie
Contao 5 Masterclass: The Beachside Project
Contao 5 Bundle Entwicklung: Die Masterclass für echte Entwickler Pillar
Contao 5 Bundle Setup: Das Fundament für professionelle Erweiterungen
Contao 5 Doctrine Entities: Moderne Datenmodellierung statt SQL-Chaos
Contao 5 DCA: Perfekte Backend-Masken für Entities erstellen
Contao 5 Doctrine Relations: Wir bauen die Buchungs-Logik
Contao 5 Service Layer: Business-Logik sauber kapseln
Contao 5 Unit Testing: Code-Absicherung mit PHPUnit & Test-Case
Contao 5 Twig Templates: Frontend-Ausgabe mit Fragment Controllern
Contao 5 Symfony Forms: Professionelle Formularverarbeitung
Contao 5 Notification Center: Zentrale Kommunikation & E-Mail-Workflows
Contao 5 Backend Dashboards & Custom Routing
Contao 5 Console Commands: Automatisierung & Hintergrund-Jobs
Contao 5 Headless CMS API: REST-Schnittstellen bauen
Häufig gestellte Fragen (FAQ)
Zusammenfassung & Ausblick
Du hast jetzt den Frontend-Output gemeistert.
Wir nutzen Controller statt PHP-Skripte.
Wir rendern sicher mit Twig.
Wir nutzen Core-Features wie
contao_figureundattrs.
Unsere Besucher sehen jetzt die Strandkörbe. Aber sie können noch nicht klicken. Der "Buchen"-Button ist noch tot. Im nächsten Teil bauen wir das Formular. Und nein, wir nutzen nicht den Formulargenerator. Wir nutzen Symfony Forms. Das ist die Königsklasse.
Nächster Artikel: Formulare mit Symfony Forms verarbeiten

Dietrich Bojko
Senior Webentwickler
Webinteger arbeitet seit vielen Jahren produktiv mit
Linux-basierten Entwicklungsumgebungen unter Windows.
Der Fokus liegt auf
performanten Setups mit WSL 2, Docker, PHP, Node.js und modernen
Build-Tools in realen Projekten –
nicht auf theoretischen Beispielkonfigurationen.
Die Artikel dieser Serie entstehen direkt aus dem täglichen Einsatz in Kunden- und Eigenprojekten und dokumentieren bewusst auch typische Fehler, Engpässe und bewährte Workarounds.


