
Contao 5 Backend Dashboards & Custom Routing

In den vergangenen Modulen haben wir eine komplette Strandkorb-Buchungsstrecke im Frontend aufgebaut. Wenn du nun dem Administrator eine moderne Kommandozentrale bieten willst, ist Contao 5 Backend Custom Routing der wichtigste Baustein für deinen Erfolg. Aktuell sieht der Vermieter nach dem Login nur die Standard-Listenansicht des Data Container Arrays (DCA), die wir in Teil 3 konfiguriert haben.
Das DCA ist fantastisch für standardisierte CRUD-Operationen (Create, Read, Update, Delete) und nimmt uns das Schreiben endloser Formulare ab. Aber: Das DCA ist kein Tool zur Datenvisualisierung. Wenn der Vermieter auf einen Blick wissen will, wie viele Buchungen heute anstehen, wie hoch der monatliche Umsatz ist oder welche Strandkörbe aktuell defekt sind, stößt das DCA an seine architektonischen Grenzen. Die moderne und updatesichere Lösung für dieses Problem lautet: Contao 5 Backend Custom Routing in Kombination mit individuellen Symfony Controllern.
Die Architektur-Evolution: Von BE_MOD zu Symfony Routes
Wenn du aus der Contao 3 oder frühen Contao 4 Welt kommst, kennst du wahrscheinlich noch das Array $GLOBALS['BE_MOD'] in der config.php. Um ein eigenes Modul ins Backend zu bringen, hat man dort eine Callback-Klasse definiert, die ein wildes Gemisch aus HTML und PHP-Logik zurückgegeben hat.
Mit Contao 5.x ändert sich das grundlegend. Contao ist nun eine waschechte Symfony-Applikation. Das Backend ist nicht länger eine abgeschottete Legacy-Insel, sondern wird von demselben mächtigen Routing-System gesteuert wie der Rest der Applikation.
Die 3 Säulen eines modernen Contao 5 Backends:
Der Backend-Controller: Eine ganz normale Symfony Controller-Klasse, die über PHP 8 Attribute (
#[Route]) definiert wird. Die Magie passiert über den Scope: Indem wir der Route den Parameter_scope => 'backend'mitgeben, weiß Contao, dass diese Route durch die Backend-Firewall geschützt werden muss (nur eingeloggte Admins/User haben Zugriff) und das Backend-Theme geladen wird.Das Backend-Twig-Template: Wir schreiben kein HTML mehr per
echoin den Code. Wir nutzen Twig. Contao 5 liefert ein Basis-Template (@Contao/backend/layout.html.twig), in das wir uns einklinken können. So sieht unser Dashboard sofort aus wie ein nativer Teil von Contao (inklusive Header, Sidebar und Footer).Der KnpMenu-Builder: Um unseren neuen Controller in der linken dunklen (oder hellen) Backend-Navigation sichtbar zu machen, nutzen wir einen Event Subscriber. Wir klinken uns in das
contao.backend_menu_buildEvent ein und fügen unseren Menüpunkt programmatisch und typsicher hinzu.
Der Plan für dieses Modul
Wir werden durch gezieltes Contao 5 Backend Custom Routing eine echte Kommandozentrale für unsere Strandkorb-Applikation bauen:
Wir erstellen den DashboardController, der Daten über Doctrine aggregiert (Umsatz, heutige Anreisen).
Wir integrieren die Route nahtlos in das Contao Backend-Menü.
Wir bauen ein modernes Twig-Template, das die Daten ansprechend als Dashboard-Cards visualisiert.
Wir schauen uns an, wie wir Custom Assets (CSS/JS) ins Backend laden, um interaktive Diagramme (z.B. Chart.js) zu rendern.

1. Der Backend Controller: Zentrale für Daten und Logik
In der klassischen Contao-Entwicklung war das Erstellen einer eigenen Backend-Seite oft ein steiniger Weg. Man musste DCA-Dateien (Data Container Array) manipulieren, globale $GLOBALS['BE_MOD'] Arrays anpassen und unübersichtliche Callback-Klassen schreiben, die HTML-Code per echo ausgaben.
Mit Contao 5 Backend Custom Routing lassen wir dieses Legacy-Konzept hinter uns. Wir nutzen exakt dieselben Mechanismen, die Symfony-Entwickler weltweit verwenden: Einen einfachen, sauberen Controller.
Wir erstellen nun unseren DashboardController. Seine Aufgabe: Er soll aus unserer Booking-Tabelle (die wir in Teil 4 erstellt haben) alle anstehenden Buchungen von heute auslesen und den Gesamtumsatz des Monats berechnen. Diese aggregierten Daten übergibt er dann an ein Twig-Template.
Erstelle die Datei /src/BeachsideBundle/src/Controller/Backend/DashboardController.php:
1<?php
2
3namespace Acme\BeachsideBundle\Controller\Backend;
4
5use Acme\BeachsideBundle\Repository\BookingRepository;
6use Contao\CoreBundle\Controller\AbstractBackendController;
7use Symfony\Component\HttpFoundation\Response;
8use Symfony\Component\Routing\Annotation\Route;
9
10/**
11 * Durch das Attribut #[Route] definieren wir die URL.
12 * %contao.backend.route_prefix% wird dynamisch durch '/contao' ersetzt.
13 * Der _scope => 'backend' ist der wichtigste Teil für Contao!
14 */
15#[Route(
16 '%contao.backend.route_prefix%/beachside-dashboard',
17 name: 'beachside_backend_dashboard',
18 defaults: ['_scope' => 'backend']
19)]
20class DashboardController extends AbstractBackendController
21{
22 public function __construct(
23 private readonly BookingRepository $bookingRepository
24 ) {
25 }
26
27 public function __invoke(): Response
28 {
29 // 1. Daten über das Doctrine Repository aggregieren
30 // (Diese Methoden würden wir im BookingRepository implementieren)
31 $todayArrivals = $this->bookingRepository->findArrivalsByDate(new \DateTimeImmutable('today'));
32 $monthlyRevenue = $this->bookingRepository->calculateRevenueForMonth(new \DateTimeImmutable('first day of this month'));
33 $activeChairsCount = $this->bookingRepository->countActiveRentals();
34
35 // 2. Das Backend-Template rendern und Daten übergeben
36 return $this->render('@AcmeBeachside/backend/dashboard.html.twig', [
37 'arrivals' => $todayArrivals,
38 'revenue' => $monthlyRevenue,
39 'active_chairs' => $activeChairsCount,
40 // Optionale Meta-Daten für den Header
41 'title' => 'Strandkorb Dashboard',
42 ]);
43 }
44}Die Magie hinter dem Code analysieren
Warum ist dieser Ansatz so revolutionär für Contao-Entwickler?
_scope => 'backend': Das ist das absolute Schlüsselelement beim Contao 5 Backend Custom Routing. Symfony kennt standardmäßig keinen Unterschied zwischen Frontend und Backend. Durch diesen Scope-Parameter im Route-Attribut passieren in Contao im Hintergrund drei Dinge vollautomatisch:Die Route wird hinter die Backend-Firewall gelegt. Wenn du die URL aufrufst und nicht eingeloggt bist, leitet dich Contao sofort zur
/contao/loginMaske um.Das Backend-Theme (CSS, JavaScript, Icons) wird geladen.
Der Contao Request-Context wird auf "Backend" gesetzt (wichtig für Insert-Tags oder Spracheinstellungen).
%contao.backend.route_prefix%: Anstatt die Route hart auf/contao/beachside-dashboardzu setzen, nutzen wir diesen Parameter. Das ist eine Best Practice, da Administratoren die Backend-URL aus Sicherheitsgründen in derconfig/config.yamländern können (z.B. auf/admin-login). Unsere Route passt sich automatisch an!AbstractBackendController: Indem wir von dieser Contao-Core-Klasse erben (anstelle des generischen SymfonyAbstractController), stellen wir sicher, dass spezielle Contao-Abhängigkeiten (wie CSRF-Token für Backend-Formulare oder das Backend-User-Objekt) sofort verfügbar sind.
Wir haben jetzt eine funktionierende URL, die von der Contao-Firewall geschützt ist und echte Datenbank-Daten sammelt. Wenn wir die URL (deinedomain.de/contao/beachside-dashboard) jetzt direkt im Browser aufrufen würden, bekämen wir allerdings einen Fehler: Wir haben das Twig-Template noch nicht angelegt, und im linken Backend-Menü fehlt der Button, um das Dashboard überhaupt anzuklicken.
Genau darum kümmern wir uns im nächsten Schritt: Wir bauen unseren Controller tief in die linke Hauptnavigation von Contao ein.

2. Die Backend-Navigation erweitern (KnpMenu)
In Contao 5 wird das Backend-Menü nicht mehr starr über ein statisches Array aufgebaut, sondern über die mächtige KnpMenuBundle Bibliothek von Symfony generiert. Der große Vorteil: Das Menü wird dynamisch zur Laufzeit erstellt. Das bedeutet, wir können uns über das Event-System von Symfony genau in dem Moment einklinken, in dem Contao den Navigationsbaum aufbaut, und unseren eigenen Menüpunkt (einen sogenannten Node oder Knoten) hinzufügen.
Das Event, auf das wir hören müssen, lautet contao.backend_menu_build.
(H2) 3. Den Event Listener programmieren
Wir erstellen nun eine Klasse, die dieses Event abfängt. Dank der modernen PHP 8 Attribute in Contao 5 brauchen wir dafür keine komplizierte services.yaml Konfiguration mehr. Das Attribut #[AsEventListener] reicht völlig aus.
Erstelle die Datei /src/BeachsideBundle/src/EventListener/BackendMenuSubscriber.php:
1<?php
2
3namespace Acme\BeachsideBundle\EventListener;
4
5use Acme\BeachsideBundle\Controller\Backend\DashboardController;
6use Contao\CoreBundle\Event\MenuEvent;
7use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
8use Symfony\Component\HttpFoundation\RequestStack;
9
10// Wir klinken uns in den Aufbau des Backend-Menüs ein.
11// Wichtig: Wir setzen die Priorität auf einen negativen Wert (-255),
12// damit die Standard-Kategorien (wie 'content') bereits existieren,
13// wenn unser Code ausgeführt wird.
14#[AsEventListener(event: 'contao.backend_menu_build', priority: -255)]
15class BackendMenuSubscriber
16{
17 public function __construct(
18 private readonly RequestStack $requestStack
19 ) {
20 }
21
22 public function __invoke(MenuEvent $event): void
23 {
24 // 1. Den Menü-Baum (Tree) und die Factory aus dem Event holen
25 $tree = $event->getTree();
26 $factory = $event->getFactory();
27
28 // 2. Wir suchen die Kategorie 'content' (Inhalte), um uns dort einzuhängen.
29 // Du könntest alternativ auch eine komplett neue Hauptkategorie erstellen.
30 $contentNode = $tree->getChild('content');
31
32 if (null === $contentNode) {
33 return;
34 }
35
36 // 3. Unseren neuen Menüpunkt (Node) generieren
37 // Wir verknüpfen ihn direkt mit der Klasse unseres Controllers!
38 $node = $factory
39 ->createItem('beachside_dashboard', [
40 'route' => DashboardController::class,
41 ])
42 ->setLabel('🏖️ Strandkorb Dashboard')
43 ->setLinkAttribute('title', 'Zur Strandkorb Kommandozentrale')
44 ->setLinkAttribute('class', 'beachside-dashboard-icon');
45
46 // 4. Den aktiven Status (Current State) berechnen
47 // Wir prüfen, ob der aktuelle Request von unserem Controller stammt.
48 // Wenn ja, markieren wir den Menüpunkt als 'aktiv' (er wird im Backend hervorgehoben).
49 $request = $this->requestStack->getCurrentRequest();
50 if ($request !== null) {
51 $currentController = $request->attributes->get('_controller');
52 $node->setCurrent($currentController === DashboardController::class);
53 }
54
55 // 5. Den Node als erstes Element in die Kategorie 'content' einfügen
56 // addChild fügt es am Ende an. Mit reorderChildren könnten wir es sortieren.
57 $contentNode->addChild($node);
58 }
59}Analyse: Warum ist das so robust?
Dieser Ansatz ist ein Meisterwerk der Contao 5 Backend Custom Routing Architektur:
Keine Hardcoded URLs: Schau dir das Array im
createIteman:'route' => DashboardController::class. Wir tippen hier keine URL wie/contao/beachside-dashboardein. KnpMenu ist intelligent genug, um Symfony nach der URL zu fragen, die zumDashboardControllergehört. Wenn du die Route in Iteration 2 jemals änderst, passt sich das Menü automatisch an!Sichere Active-States: Nichts ist nerviger in eigenen Backend-Modulen als kaputte "Aktiv"-Zustände in der Navigation. Da wir den aktuellen Request (
$request->attributes->get('_controller')) gegen unsere Controller-Klasse prüfen, ist der Menüpunkt immer 100% zuverlässig hervorgehoben, wenn der User sich auf dem Dashboard befindet.Positionierung: Da wir eine Priorität von
-255für unseren Event Listener definiert haben, garantieren wir, dass der Contao-Core sein Basis-Menü bereits aufgebaut hat. Wir können uns also gefahrlos über$tree->getChild('content')in den existierenden Baum einklinken.
Nach dem Leeren des Caches (contao-console cache:clear) wirst du beim nächsten Login ins Contao Backend in der linken Navigation unter "Inhalte" den neuen Eintrag "🏖️ Strandkorb Dashboard" sehen.
Wenn du jetzt darauf klickst, ruft Contao unseren Controller auf. Da wir aber das Twig-Template noch nicht geschrieben haben, wird dir Symfony eine "Template not found"-Fehlermeldung werfen. Das ist unser perfekter Übergang für den nächsten Schritt.

4. Die View: Das Backend Twig-Template
Wenn du in Iteration 3 auf den neuen Menüpunkt "Strandkorb Dashboard" geklickt hast, hat Symfony einen Fehler geworfen, weil die Datei @AcmeBeachside/backend/dashboard.html.twig noch nicht existiert. Das ändern wir jetzt.
Der größte Fehler, den Entwickler beim Contao 5 Backend Custom Routing machen, ist, das Backend-HTML komplett von Grund auf neu zu schreiben. Das führt dazu, dass dein Modul bei jedem Contao-Update (z.B. Darkmode-Anpassungen) kaputt aussieht.
Die Lösung: Wir erben vom offiziellen Contao-Backend-Layout. Dadurch erhalten wir automatisch den Header, die linke Navigation, den Footer und alle Basis-CSS-Klassen des Contao-Cores.
Erstelle die Datei /src/BeachsideBundle/templates/backend/dashboard.html.twig:
1{# Wir erben vom nativen Contao 5 Backend-Layout #}
2{% extends "@Contao/backend/layout.html.twig" %}
3
4{# Wir überschreiben den Block 'main', der den Hauptinhaltsbereich darstellt #}
5{% block main %}
6
7 {#
8 WICHTIG: Wir nutzen die nativen Contao-CSS-Klassen (tl_main_headline etc.),
9 damit sich unser Dashboard nahtlos in das restliche System einfügt.
10 #}
11 <div>
12 <h1>{{ title|default('Strandkorb Kommandozentrale') }}</h1>
13 </div>
14
15 {#
16 Das ist der Hauptcontainer für unseren Inhalt.
17 Wir nutzen hier auch das integrierte Contao-Markup für Benachrichtigungen.
18 #}
19 <div id="tl_messages">
20 {% if arrivals|length > 0 %}
21 <p>
22 Achtung: Heute reisen {{ arrivals|length }} neue Gäste an!
23 </p>
24 {% endif %}
25 </div>
26
27 <div>
28
29 {# Unser eigenes Dashboard-Grid (das CSS dafür schreiben wir in Iteration 5) #}
30 <div>
31
32 <div>
33 <div>Heutige Anreisen</div>
34 <div>
35 <span>{{ arrivals|length }}</span>
36 <span>Strandkörbe vorzubereiten</span>
37 </div>
38 </div>
39
40 <div>
41 <div>Aktuell am Strand</div>
42 <div>
43 <span>{{ active_chairs }}</span>
44 <span>von 150 Körben belegt</span>
45 </div>
46 </div>
47
48 <div>
49 <div>Umsatz (laufender Monat)</div>
50 <div>
51 {#
52 Pro-Tipp: format_currency benötigt das Paket 'twig/intl-extra'.
53 Alternativ: {{ revenue|number_format(2, ',', '.') }} €
54 #}
55 <span>{{ revenue|format_currency('EUR', {fraction_digit: 0}) }}</span>
56 </div>
57 </div>
58
59 </div>
60
61 </div>
62
63{% endblock %}Die Anatomie des Templates verstehen
Lass uns diesen Code analysieren:
{% extends "@Contao/backend/layout.html.twig" %}: Dieser Einzeiler ist pures Gold. Er sagt Symfony: "Nimm das komplette Contao-Backend-Grundgerüst. Kümmere dich um den Darkmode, lade die Contao-Icons, render die Menüs – ich will nur den Mittelteil austauschen."{% block main %}: Dies ist der definierte Twig-Block im Contao Core-Template, der für den Inhaltsbereich rechts neben der Navigation reserviert ist.tl_main_headline&tl_info: Wer Contao kennt, kennt das Präfixtl_(Table). Wenn wir diese Core-Klassen verwenden, sieht unsere Überschrift exakt so aus wie die Überschriften in der Artikelverwaltung. Auch die Hinweis-Boxen (tl_info,tl_error) übernehmen automatisch das korrekte Styling des aktiven Backend-Themes.Daten-Bindung: Die Variablen
arrivals,active_chairsundrevenuehaben wir in Iteration 2 in unserem Controller definiert und an dieses Template übergeben. Twig rendert diese nun sicher aus.
Wenn du jetzt im Backend auf deinen Menüpunkt klickst, siehst du dein Dashboard! Es hat die Navigation, es hat den Header, es zeigt deine Daten.
Aber ein Problem bleibt: Unsere eigenen Klassen wie beachside-dashboard-grid oder dashboard-card haben noch kein CSS. Die Karten kleben unschön untereinander, anstatt sich in einem modernen Grid anzuordnen. Außerdem wollen wir vielleicht ein JavaScript-Chart (z.B. Chart.js) einbinden, um die Umsätze der letzten 12 Monate als Kurve darzustellen.
Wie wir eigene CSS- und JavaScript-Dateien (Assets) in unser Contao 5 Backend Custom Routing einschleusen, zeige ich dir im nächsten Schritt.

5. Assets einbinden: Der moderne Weg in Contao 5
In der alten Contao-Welt hast du eigene CSS- oder JavaScript-Dateien oft mühsam über $GLOBALS['TL_CSS'] oder $GLOBALS['TL_JAVASCRIPT'] in DCA-Dateien eingeschleust. Das Problem: Diese Skripte wurden dann oft auf jeder Backend-Seite geladen, auch wenn sie dort gar nicht gebraucht wurden, was die Performance des Backends verschlechtert hat.
Beim Contao 5 Backend Custom Routing haben wir die volle Kontrolle. Da wir ein eigenes Twig-Template haben, das vom Contao-Core-Layout erbt, können wir gezielt die Twig-Blöcke für Stylesheets und Skripte überschreiben.
6. Das Twig-Template erweitern
Wir öffnen unsere Datei templates/backend/dashboard.html.twig aus Iteration 4 und fügen ganz oben (direkt unter dem extends-Befehl) die Blöcke für unsere Assets hinzu:
1{% extends "@Contao/backend/layout.html.twig" %}
2
3{# 1. Eigene CSS-Dateien laden #}
4{% block stylesheets %}
5 {# Ganz wichtig: parent() aufrufen, sonst fehlt das komplette Contao-CSS! #}
6 {{ parent() }}
7
8 {# Wir nutzen die Symfony asset() Funktion #}
9 <link rel="stylesheet" href="{{ asset('bundles/acmebeachside/css/dashboard.css') }}">
10{% endblock %}
11
12{# 2. Eigene JavaScript-Dateien laden (z.B. für ein Umsatz-Diagramm) #}
13{% block javascripts %}
14 {{ parent() }}
15
16 {# Externe Bibliothek laden (Chart.js) #}
17 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
18
19 {# Unser eigenes Skript laden #}
20 <script src="{{ asset('bundles/acmebeachside/js/dashboard.js') }}"></script>
21{% endblock %}
22
23{% block main %}
24 {# ... hier bleibt unser Code aus Iteration 4 ... #}
25
26 {# Wir fügen in unserem Grid noch einen Platzhalter für das Diagramm hinzu: #}
27 <div>
28 <div>
29 <canvas id="revenueChart" width="400" height="150"></canvas>
30 </div>
31 </div>
32{% endblock %}7. Wo müssen die Dateien liegen? (Symfony Asset Management)
Du siehst im Code den Pfad bundles/acmebeachside/.... Wenn du ein eigenes Contao-Bundle (wie unser AcmeBeachsideBundle) entwickelst, legst du deine CSS- und JS-Dateien im Ordner src/Resources/public/ (bzw. public/ in neueren Bundle-Strukturen) ab.
Ordnerstruktur:
/src/BeachsideBundle/public/css/dashboard.css/src/BeachsideBundle/public/js/dashboard.js
Der entscheidende Schritt: Damit der Webbrowser diese Dateien erreichen kann, müssen sie in den zentralen public/-Ordner deiner Contao-Installation gespiegelt werden. Das erledigt Symfony automatisch für dich, wenn du folgenden Befehl ausführst:
composer install
# oder explizit:
vendor/bin/contao-console assets:installSymfony erstellt nun einen Symlink (eine Verknüpfung) von public/bundles/acmebeachside/ zu deinem Bundle.
8. Das CSS und JavaScript (Beispiel)
In der dashboard.css können wir nun unser CSS Grid definieren:
1.beachside-dashboard-grid {
2 display: grid;
3 grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
4 gap: 20px;
5 margin-top: 20px;
6}
7.dashboard-card {
8 background: var(--bg-color); /* Nutzt Contao Darkmode Variablen! */
9 border: 1px solid var(--border-color);
10 border-radius: 6px;
11 padding: 20px;
12 box-shadow: 0 2px 4px rgba(0,0,0,0.05);
13}
14.metric-huge {
15 font-size: 2.5rem;
16 font-weight: bold;
17 color: #ff6600; /* Unser Neon-Orange */
18}In der dashboard.js initialisieren wir Chart.js:
1document.addEventListener('DOMContentLoaded', function() {
2 const ctx = document.getElementById('revenueChart');
3 if (ctx) {
4 new Chart(ctx, {
5 type: 'bar',
6 data: {
7 labels: ['Mai', 'Juni', 'Juli', 'August'],
8 datasets: [{
9 label: 'Umsatz in €',
10 data: [1200, 3500, 4800, 5200],
11 backgroundColor: '#ff6600'
12 }]
13 }
14 });
15 }
16});Mit diesem Setup sieht unser Backend-Dashboard nicht nur fantastisch aus, es ist auch hochgradig interaktiv. Die Trennung ist sauber: PHP beschafft die Daten (Controller), Twig baut das HTML, CSS sorgt für das Layout und JS für die Interaktivität.
Im nächsten Schritt widmen wir uns der Sicherheit. Was passiert, wenn ein normaler Redakteur (ohne Admin-Rechte) das Dashboard aufruft? Wir müssen Berechtigungen definieren!

9. Das Sicherheitsproblem beim Custom Routing
In der alten Contao-Welt (mit klassischen DCA-Modulen) hast du in den Benutzergruppen im Backend einfach Checkboxen angeklickt, um festzulegen, wer ein Modul sehen darf.
Beim Contao 5 Backend Custom Routing haben wir dieses Konstrukt verlassen. Wir haben eine direkte Symfony-Route gebaut. Zwar schützt der Parameter _scope => 'backend' davor, dass unangemeldete Gäste (Frontend-Besucher) die Seite sehen, aber jeder eingeloggte Backend-Nutzer (auch ein reiner News-Redakteur ohne Admin-Rechte) könnte aktuell die URL /contao/beachside-dashboard aufrufen und unsere sensiblen Umsatzdaten sehen!
Wir müssen unser Dashboard absichern. In einer modernen Symfony-Architektur nutzen wir dafür das Attribut #[IsGranted].
10. Den Controller absichern
Wir öffnen unseren DashboardController aus Iteration 2 und fügen genau eine Zeile Code hinzu. Für dieses Beispiel sagen wir: Nur Administratoren (ROLE_ADMIN) dürfen das Dashboard aufrufen.
(Hinweis: Für feinere Rechte könntest du auch eigene Contao-Voter programmieren, z.B. prüfen ob der User Zugriff auf die Tabelle tl_booking hat, aber für ein globales Dashboard ist ROLE_ADMIN oft die erste Wahl).
1<?php
2
3namespace Acme\BeachsideBundle\Controller\Backend;
4
5use Contao\CoreBundle\Controller\AbstractBackendController;
6use Symfony\Component\HttpFoundation\Response;
7use Symfony\Component\Routing\Annotation\Route;
8use Symfony\Component\Security\Http\Attribute\IsGranted; // <-- NEU
9
10#[Route('%contao.backend.route_prefix%/beachside-dashboard', name: 'beachside_backend_dashboard', defaults: ['_scope' => 'backend'])]
11#[IsGranted('ROLE_ADMIN')] // <-- DIESE ZEILE IST DIE MAGIE!
12class DashboardController extends AbstractBackendController
13{
14 // ... restlicher Code ...
15}Wenn sich nun ein normaler Redakteur anmeldet und die URL manuell im Browser eintippt, wirft Symfony sofort eine 403 Access Denied (Zugriff verweigert) Exception. Das Dashboard ist sicher!
11. Die Navigation dynamisch ausblenden
Wir haben ein neues Problem geschaffen: Der Controller ist jetzt geschützt, aber unser Event Listener aus Iteration 3 (BackendMenuSubscriber) fügt den Menüpunkt "🏖️ Strandkorb Dashboard" immer noch für jeden in die linke Navigation ein. Klickt der Redakteur darauf, sieht er eine rote Fehlermeldung. Das ist schlechte User Experience (UX).
Wir müssen unseren BackendMenuSubscriber so umbauen, dass er den Menüpunkt nur dann in den Navigationsbaum einhängt, wenn der User auch die Rechte dafür hat. Dazu injizieren wir den AuthorizationCheckerInterface von Symfony.
Öffne /src/BeachsideBundle/src/EventListener/BackendMenuSubscriber.php:
1<?php
2
3namespace Acme\BeachsideBundle\EventListener;
4
5use Acme\BeachsideBundle\Controller\Backend\DashboardController;
6use Contao\CoreBundle\Event\MenuEvent;
7use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
8use Symfony\Component\HttpFoundation\RequestStack;
9use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; // <-- NEU
10
11#[AsEventListener(event: 'contao.backend_menu_build', priority: -255)]
12class BackendMenuSubscriber
13{
14 public function __construct(
15 private readonly RequestStack $requestStack,
16 private readonly AuthorizationCheckerInterface $security // <-- NEU injiziert
17 ) {
18 }
19
20 public function __invoke(MenuEvent $event): void
21 {
22 // 1. Sicherheitsprüfung BEVOR wir etwas tun!
23 // Hat der User NICHT die Rolle Admin? Dann brich sofort ab!
24 if (!$this->security->isGranted('ROLE_ADMIN')) {
25 return;
26 }
27
28 // 2. Den Menü-Baum holen
29 $tree = $event->getTree();
30 $contentNode = $tree->getChild('content');
31
32 if (null === $contentNode) {
33 return;
34 }
35
36 // ... restlicher Code zum Erstellen des Menüpunkts aus Iteration 3 ...
37 }
38}Das perfekte Zusammenspiel
Dies ist die absolute Best Practice für Contao 5 Backend Custom Routing:
Backend-Firewall (
_scope): Verhindert den Zugriff ohne Login.Controller-Security (
#[IsGranted]): Schützt die eigentliche Datenverarbeitung (die Logik-Schicht) vor unautorisierten eingeloggten Nutzern.Menu-Security (
AuthorizationChecker): Schützt die Präsentations-Schicht (UX), indem Elemente, auf die der User keinen Zugriff hat, gar nicht erst gerendert werden.
Dein Dashboard ist jetzt nicht nur funktional und visuell ansprechend, sondern auch auf Enterprise-Niveau abgesichert.
Im nächsten Schritt widmen wir uns einer weiteren Anforderung: Wie können wir innerhalb unseres Custom Controllers Formulare verarbeiten (z.B. ein Filter-Formular "Zeige Umsätze von Jahr X")? Hier nutzen wir wieder unser Wissen aus Teil 8!

12. Das Dashboard interaktiv machen
Aktuell zeigt unser DashboardController statisch den Umsatz des aktuellen Monats an. Was aber, wenn der Strandkorb-Vermieter wissen möchte, wie der Umsatz im Vorjahr war? Er braucht einen Filter.
In der alten Contao-DCA-Welt bedeutete das oft das mühsame Konfigurieren von "Panel Layouts" (Filter, Sortierung, Suche). Da wir uns aber im Contao 5 Backend Custom Routing befinden und einen echten Symfony-Controller nutzen, können wir auf das mächtigste Tool für Benutzereingaben zurückgreifen: Symfony Forms (die wir bereits in Teil 8 gemeistert haben).
Da ein Filter den Zustand der Seite nicht verändert (es wird nichts in die Datenbank geschrieben, nur gelesen), nutzen wir für das Formular die HTTP-Methode GET.
13. Den Controller um ein Formular erweitern
Wir passen unseren DashboardController an. Wir injizieren die FormFactoryInterface und werten den aktuellen Request aus.
(Tipp: Für komplexe Formulare würdest du wieder eine eigene FormType-Klasse anlegen. Für diesen simplen Jahres-Filter bauen wir das Formular direkt im Controller auf.)
1<?php
2
3namespace Acme\BeachsideBundle\Controller\Backend;
4
5use Acme\BeachsideBundle\Repository\BookingRepository;
6use Contao\CoreBundle\Controller\AbstractBackendController;
7use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
8use Symfony\Component\Form\Extension\Core\Type\SubmitType;
9use Symfony\Component\Form\FormFactoryInterface;
10use Symfony\Component\HttpFoundation\Request;
11use Symfony\Component\HttpFoundation\Response;
12use Symfony\Component\Routing\Annotation\Route;
13use Symfony\Component\Security\Http\Attribute\IsGranted;
14
15#[Route('%contao.backend.route_prefix%/beachside-dashboard', name: 'beachside_backend_dashboard', defaults: ['_scope' => 'backend'])]
16#[IsGranted('ROLE_ADMIN')]
17class DashboardController extends AbstractBackendController
18{
19 public function __construct(
20 private readonly BookingRepository $bookingRepository,
21 private readonly FormFactoryInterface $formFactory // <-- NEU
22 ) {
23 }
24
25 public function __invoke(Request $request): Response // <-- Request injiziert
26 {
27 // 1. Das Filter-Formular erstellen
28 $form = $this->formFactory->createBuilder()
29 ->setMethod('GET') // Wichtig für Filter-Formulare!
30 ->add('year', ChoiceType::class, [
31 'label' => 'Umsatzjahr wählen:',
32 'choices' => [
33 '2024' => 2024,
34 '2025' => 2025,
35 '2026' => 2026,
36 ],
37 'data' => (int) date('Y'), // Vorauswahl: aktuelles Jahr
38 ])
39 ->add('filter', SubmitType::class, ['label' => 'Filtern'])
40 ->getForm();
41
42 // 2. Den Request an das Formular übergeben
43 $form->handleRequest($request);
44
45 // 3. Den ausgewählten Wert auslesen (Fallback auf aktuelles Jahr)
46 $selectedYear = (int) date('Y');
47 if ($form->isSubmitted() && $form->isValid()) {
48 $selectedYear = $form->get('year')->getData();
49 }
50
51 // 4. Daten basierend auf dem Filter aus der Datenbank holen
52 $yearlyRevenue = $this->bookingRepository->calculateRevenueForYear($selectedYear);
53 $todayArrivals = $this->bookingRepository->findArrivalsByDate(new \DateTimeImmutable('today'));
54
55 // 5. Template rendern
56 return $this->render('@AcmeBeachside/backend/dashboard.html.twig', [
57 'form' => $form->createView(), // Formular an Twig übergeben
58 'revenue' => $yearlyRevenue,
59 'arrivals' => $todayArrivals,
60 'year' => $selectedYear,
61 'title' => 'Strandkorb Kommandozentrale',
62 ]);
63 }
64}14. Das Formular im Twig-Template rendern
Jetzt müssen wir das Formular nur noch in unserer dashboard.html.twig ausgeben. Da wir das nativ ins Contao Backend integrieren, nutzen wir die Standard-Formular-Funktionen von Twig. Contao kümmert sich im Backend-Theme automatisch um ein rudimentär passendes Styling für Inputs und Buttons.
Öffne deine templates/backend/dashboard.html.twig und füge den Formular-Block über dem Grid ein:
1{% extends "@Contao/backend/layout.html.twig" %}
2
3{% block main %}
4 <div>
5 <h1>{{ title }}</h1>
6 </div>
7
8 {# NEU: Der Filter-Bereich #}
9 <div>
10 {{ form_start(form, {'attr': {'class': 'tl_form'}}) }}
11
12 <div>
13 <div>
14 {{ form_label(form.year) }}<br>
15 {{ form_widget(form.year, {'attr': {'class': 'tl_select'}}) }}
16 </div>
17 <div>
18 {{ form_widget(form.filter, {'attr': {'class': 'tl_submit'}}) }}
19 </div>
20 </div>
21
22 {{ form_end(form) }}
23 </div>
24
25 {# ... unser Grid aus Iteration 4 ... #}
26 <div>
27 <div>
28 <div>Jahresumsatz ({{ year }})</div>
29 <div>
30 <span>{{ revenue|format_currency('EUR', {fraction_digit: 0}) }}</span>
31 </div>
32 </div>
33 </div>
34{% endblock %}Warum ist das so elegant?
Wenn der Vermieter nun im Backend im Dropdown das Jahr "2025" auswählt und auf "Filtern" klickt, lädt die Seite neu. Da wir als Methode GET definiert haben, ändert sich die URL oben im Browser zu: /contao/beachside-dashboard?year=2025
Das ist ein massiver Vorteil des Contao 5 Backend Custom Routing:
Bookmarks: Der Admin kann sich diese URL als Lesezeichen abspeichern und landet direkt beim Umsatzbericht für 2025.
Einfachheit: Wir müssen keine Contao-Spezialvariablen (
?do=...&act=...) abfangen. Wir arbeiten mit reinstem Symfony HTTP-Request-Handling.Sicherheit: Da wir das Formular mit
ChoiceTypedefiniert haben, blockiert Symfony automatisch jeden Versuch, wenn jemand die URL manipuliert (z.B.?year=DROP TABLE). Wenn der Wert nicht im Array der konfigurierten Jahre existiert, ist$form->isValid()false und der Controller fällt sicher auf das Default-Jahr zurück.
Dein Dashboard ist jetzt nicht nur sicher und visuell ansprechend, sondern voll interaktiv.

15. Best Practices für komplexe Backend-Module
Du hast nun ein voll funktionsfähiges, sicheres und interaktives Dashboard gebaut. Um dein Contao 5 Backend Custom Routing auch in großen und langlebigen Projekten wartbar zu halten, solltest du folgende Best Practices verinnerlichen:
Thin Controller, Fat Service: Dein Controller (
DashboardController) sollte nur den Request entgegennehmen, das Formular verarbeiten und Variablen an Twig übergeben. Schreibe niemals komplexe SQL-Queries direkt in den Controller. Nutze dafür, wie in unserem Beispiel, Doctrine Repositories (BookingRepository) oder eigene Services. Das macht deinen Code testbar und wiederverwendbar.Übersetzungen (I18n) konsequent nutzen: Anstatt
'title' => 'Strandkorb Kommandozentrale'hart in den Controller zu schreiben, solltest du den Symfony Translator nutzen. Lege einemessages.de.yamlan und nutze im Controller$this->translator->trans('backend.dashboard.title')oder im Twig-Template direkt{{ 'backend.dashboard.title'|trans }}. So machst du dein Dashboard sofort mehrsprachig für internationale Redakteure.DCA und Custom Routes kombinieren: Das DCA und eigene Controller sind keine Feinde! Du kannst in deinem Dashboard-Grid z. B. einen Button "Letzte Buchung bearbeiten" einbauen. Der Link führt dann einfach auf die native DCA-Route:
/contao?do=booking&act=edit&id=42. So nutzt du das Dashboard für die Analyse und das DCA für die reine Datenpflege.

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
Das Contao 5 Backend Custom Routing ist ein massiver Paradigmenwechsel. Wir haben uns von den Fesseln der veralteten $GLOBALS['BE_MOD'] Arrays und statischen DCA-Listen gelöst.
Durch den Einsatz von:
Symfony Controllern für die Logik
KnpMenu Event-Subscribern für die Navigation
Twig-Templates für das Erben des nativen Contao-Designs
Symfony Security (
#[IsGranted]) für rollenbasierte Zugriffe
... hast du nun das Handwerkszeug, um für deine Kunden maßgeschneiderte Software-Oberflächen direkt im Contao-Backend zu entwickeln. Ob CRM-System, komplexe Ticket-Verwaltung oder unser Strandkorb-Dashboard: Es gibt keine visuellen und logischen Grenzen mehr.
Was kommt als Nächstes? Unser System läuft, die E-Mails werden verschickt und das Backend ist poliert. Aber ein System muss auch gewartet werden. In Teil 11 widmen wir uns der Automatisierung: Wir schreiben eigene Symfony Console Commands (CLI) und richten Cronjobs ein, um z. B. nachts automatisch abgelaufene Buchungen zu archivieren oder den Cache aufzuwärmen.
Bereit für Teil 11? Console Commands & Automatisierung in Contao 5

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.


