
Contao 5 Headless CMS API: REST-Schnittstellen bauen

In den bisherigen Modulen haben wir eine fantastische, monolithische Web-Applikation aufgebaut. Wenn du dein System jedoch für die Zukunft rüsten willst, ist die Entwicklung einer Contao 5 Headless CMS API der logische nächste Schritt. Der Trend im E-Commerce und bei großen Enterprise-Projekten geht massiv in Richtung Multi-Channel. Der Vermieter unserer Strandkörbe möchte seine Körbe bald nicht mehr nur über die klassische Website vermieten, sondern auch über eine Smartphone-App, eine Smartwatch-Benachrichtigung oder ein Kiosk-Terminal direkt an der Strandpromenade.
Um all diese verschiedenen Plattformen bedienen zu können, ohne den Code fünfmal neu zu schreiben, müssen wir unsere Architektur aufbrechen. Wir machen Contao "Headless" (kopflos).
Was bedeutet "Headless" eigentlich? (Die PlayStation-Metapher)
Der Begriff "Headless" bezeichnet die architektonische Trennung (Entkopplung) von Backend (Content-Repository) und Frontend (Präsentationsschicht).
Um das einfach zu erklären, nutzen wir eine Metapher aus der Hardware-Welt: Stell dir Contao wie eine Spielkonsole (z. B. eine PlayStation) vor. Die Konsole selbst ist das Backend. Sie verarbeitet die Spieldaten, speichert Spielstände und berechnet die Grafik. Aber: Die Konsole hat keinen eigenen Bildschirm. Sie ist "Headless". Du kannst sie per HDMI-Kabel an einen riesigen Fernseher, an einen kleinen Computermonitor oder sogar an einen Beamer anschließen. Die Konsole (Contao) kümmert sich nur um die Bereitstellung der Daten. Das Endgerät (Fernseher/Frontend) entscheidet, wie diese Daten visuell dargestellt werden.
Das Kabel, das diese beiden Welten verbindet, ist unsere API (Application Programming Interface).
Der Paradigmenwechsel in Contao 5
In früheren Contao-Versionen war der Aufbau einer solchen API oft schmerzhaft. Man musste eigene Routen definieren, Daten aus Datenbank-Models mühsam in JSON-Arrays umwandeln und sich selbst um Sicherheitskonzepte wie Authentifizierung kümmern. Das war fehleranfällig und skalierte nicht gut. Erweiterungen wie das heimrichhannot/contao-api-bundle halfen zwar, deckten aber oft nur spezifische Use-Cases ab.
Mit dem vollständigen Umstieg auf die Symfony 6+ Architektur in Contao 5 ändert sich das radikal. Wir haben nun die Möglichkeit, den weltweiten Branchenstandard für PHP-APIs direkt in Contao zu nutzen: API Platform.
API Platform ist ein extrem mächtiges Framework, das sich nahtlos in Symfony einklinkt. Es generiert aus unseren einfachen PHP-Klassen (Entities) vollautomatisch:
Eine vollständige REST-API (inklusive GET, POST, PUT, DELETE).
Eine GraphQL-API.
Eine wunderschöne, interaktive Swagger-UI Dokumentation für die Frontend-Entwickler.
Paginierung, Filterung und Validierung out-of-the-box.
Der Plan für dieses Modul
Wir werden unsere Strandkorb-Plattform in ein modernes, API-gesteuertes Backend verwandeln:
Wir binden das API Platform Framework in unser Contao-Projekt ein.
Wir machen unsere Strandkorb-Buchungen als JSON-Ressource über eine sichere URL (z.B.
/api/bookings) verfügbar.Wir lernen, wie wir mit Serialization Groups steuern, dass sensible Daten (wie E-Mail-Adressen) nicht versehentlich ins Frontend geleakt werden.
Wir sichern unsere Endpunkte ab (Authentication), damit nur befugte Apps Strandkörbe buchen können.
Wir schauen uns Best Practices für den Headless-Betrieb mit modernen Frontend-Frameworks (Vue.js, Nuxt, React) an.
Mach dich bereit, das "Kabel" zwischen deinem Backend und der restlichen Welt zu verlegen!

1. API Platform in Contao 5 installieren
Da Contao 5 eine vollständige Symfony-Applikation ist, können wir direkt auf den Industriestandard für den Bau von APIs zurückgreifen: API Platform. Dieses Framework nimmt uns 90 % der lästigen Arbeit (wie das Schreiben von Controllern für CRUD-Operationen, Paginierung und Validierung) ab.
Öffne dein Terminal und installiere das Paket in deinem Contao-Root-Verzeichnis:
composer require api-platform/coreSobald die Installation abgeschlossen ist, müssen wir Symfony mitteilen, unter welcher URL unsere Contao 5 Headless CMS API erreichbar sein soll. Dazu passen wir die Routing-Konfiguration an.
Erstelle oder ergänze die Datei config/routes.yaml:
# config/routes.yaml
api_platform:
resource: .
type: api_platform
prefix: /apiWenn du jetzt deinen Cache leerst (php vendor/bin/contao-console cache:clear) und in deinem Browser die URL deinedomain.de/api aufrufst, siehst du bereits die wunderschöne, automatisch generierte Swagger-UI (eine interaktive API-Dokumentation). Aktuell ist sie jedoch noch leer, da wir noch keine Daten freigegeben haben.
2. Die erste Entity als API-Ressource definieren
Erinnerst du dich an unsere Doctrine Entity Booking aus Teil 4? Diese Klasse repräsentiert unsere Datenbanktabelle tl_booking.
In alten Contao-Versionen hätten wir nun mühsam einen API-Controller schreiben müssen, der die Datenbank abfragt, die Daten in ein JSON-Array umwandelt und dieses zurückgibt. Mit API Platform reicht dafür ein einziges PHP 8 Attribut!
Wir öffnen unsere Klasse src/Entity/Booking.php und fügen das Attribut #[ApiResource] hinzu:
1<?php
2
3namespace Acme\BeachsideBundle\Entity;
4
5use ApiPlatform\Metadata\ApiResource; // <-- NEU
6use Doctrine\DBAL\Types\Types;
7use Doctrine\ORM\Mapping as ORM;
8
9// Durch dieses Attribut wird die Klasse sofort zu einem API-Endpunkt!
10#[ApiResource]
11#[ORM\Entity]
12#[ORM\Table(name: 'tl_booking')]
13class Booking
14{
15 #[ORM\Id]
16 #[ORM\GeneratedValue]
17 #[ORM\Column]
18 private ?int $id = null;
19
20 #[ORM\Column(length: 255)]
21 private ?string $customerName = null;
22
23 #[ORM\Column(length: 255)]
24 private ?string $email = null;
25
26 #[ORM\Column(type: Types::DATE_IMMUTABLE)]
27 private ?\DateTimeImmutable $dateStart = null;
28
29 // ... Getter und Setter ...
30}3. Die Magie erleben
Lade nun deine Seite deinedomain.de/api im Browser neu. Was du jetzt siehst, ist pure Magie der modernen Webentwicklung: API Platform hat unsere Booking-Entity analysiert und vollautomatisch folgende REST-Endpunkte generiert und detailliert dokumentiert:
GET /api/bookings(Holt eine Liste aller Buchungen, standardmäßig mit Paginierung von 30 Einträgen pro Seite).POST /api/bookings(Legt eine neue Buchung an).GET /api/bookings/{id}(Holt die Details einer einzelnen Buchung).PUT /api/bookings/{id}(Aktualisiert eine komplette Buchung).PATCH /api/bookings/{id}(Aktualisiert Teile einer Buchung).DELETE /api/bookings/{id}(Löscht eine Buchung).
Wenn du in der Swagger-UI auf den Button Try it out bei GET /api/bookings klickst und den Request absendest, erhältst du sofort ein perfekt formatiertes JSON-Array mit echten Strandkorb-Buchungen aus deiner Contao-Datenbank (im JSON-LD Format, welches ideal für SEO und verknüpfte Daten ist).
Aber wir haben jetzt ein massives Problem geschaffen! Aktuell gibt die API alle Felder unserer Datenbanktabelle zurück – inklusive sensibler Daten wie der privaten E-Mail-Adresse des Kunden. Außerdem könnte momentan jeder Besucher der Website einfach einen DELETE-Request senden und unsere Buchungen löschen.
Wie wir die Datenausgabe filtern und unsere API absichern, klären wir im nächsten Schritt!

4. Das Problem des "Data Leakings"
Als wir im vorherigen Schritt das Attribut #[ApiResource] zu unserer Booking-Klasse hinzugefügt haben, hat API Platform standardmäßig einfach alle Eigenschaften (customerName, email, dateStart) in das JSON-Format übersetzt (serialisiert) und an den Browser geschickt.
Für eine öffentliche Contao 5 Headless CMS API ist das ein absolutes No-Go. Stell dir vor, du baust eine Strandkorb-Verfügbarkeits-App, die alle aktuell gebuchten Körbe anzeigt. Die App muss wissen, von wann bis wann ein Korb gebucht ist (dateStart, dateEnd), aber sie darf unter keinen Umständen die private E-Mail-Adresse (email) des Buchenden an alle App-Nutzer ausliefern. Das wäre ein massiver DSGVO-Verstoß.
Die elegante Lösung in der Symfony-Welt lautet: Serialization Groups (Serialisierungs-Gruppen).
5. Serialization Groups: Der Türsteher für deine Daten
Mit Serialization Groups definieren wir exakt, welche Felder ausgelesen (Read / Normalization) und welche Felder von außen geschrieben (Write / Denormalization) werden dürfen.
Wir öffnen wieder unsere Datei src/Entity/Booking.php und erweitern sie um die Konfiguration für die Serialisierung:
1<?php
2
3namespace Acme\BeachsideBundle\Entity;
4
5use ApiPlatform\Metadata\ApiResource;
6use Doctrine\DBAL\Types\Types;
7use Doctrine\ORM\Mapping as ORM;
8use Symfony\Component\Serializer\Annotation\Groups; // <-- WICHTIG: Import hinzufügen!
9
10// 1. Wir definieren die Kontexte für Lesen (Read) und Schreiben (Write)
11#[ApiResource(
12 normalizationContext: ['groups' => ['booking:read']],
13 denormalizationContext: ['groups' => ['booking:write']]
14)]
15#[ORM\Entity]
16#[ORM\Table(name: 'tl_booking')]
17class Booking
18{
19 #[ORM\Id]
20 #[ORM\GeneratedValue]
21 #[ORM\Column]
22 // IDs dürfen immer gelesen, aber niemals von außen geschrieben (manipuliert) werden!
23 #[Groups(['booking:read'])]
24 private ?int $id = null;
25
26 #[ORM\Column(length: 255)]
27 // Den Namen darf man beim Buchen angeben (write) und auslesen (read)
28 #[Groups(['booking:read', 'booking:write'])]
29 private ?string $customerName = null;
30
31 #[ORM\Column(length: 255)]
32 // WICHTIG: Die E-Mail darf man beim Buchen angeben (write),
33 // aber sie wird NIEMALS über die API ausgelesen (kein 'booking:read')!
34 #[Groups(['booking:write'])]
35 private ?string $email = null;
36
37 #[ORM\Column(type: Types::DATE_IMMUTABLE)]
38 // Das Datum ist öffentlich lesbar und schreibbar
39 #[Groups(['booking:read', 'booking:write'])]
40 private ?\DateTimeImmutable $dateStart = null;
41
42 // ... Getter und Setter ...
43}6. Wie funktioniert das im Detail?
Lass uns die Fachbegriffe aufschlüsseln, damit du verstehst, was hier im Hintergrund deiner Contao 5 Headless CMS API passiert:
Normalization (Lesen / GET): Der Prozess, bei dem ein komplexes PHP-Objekt (aus der Datenbank) in ein flaches JSON-Array "normalisiert" wird, um es an den Client (z. B. die Smartphone-App) zu senden. Wir haben definiert, dass hierfür nur Felder verwendet werden dürfen, die das Attribut
#[Groups(['booking:read'])]besitzen.Denormalization (Schreiben / POST, PUT): Der umgekehrte Weg. Das JSON-Array, das vom Smartphone an unseren Server geschickt wird, wird in ein PHP-Objekt "denormalisiert". Hierbei werden nur Felder akzeptiert, die
#[Groups(['booking:write'])]besitzen. Versucht ein Hacker, bei einem POST-Request dieidkünstlich auf9999zu setzen, ignoriert API Platform dieses Feld einfach, da dieidnicht in derbooking:writeGruppe ist.
Wenn du nun die Swagger-UI (deinedomain.de/api) neu lädst und den GET /api/bookings Endpunkt ausführst, wirst du sehen: Die Datenbank liefert dir zwar die Buchungen, aber das Feld email ist aus dem JSON-Output komplett verschwunden! Deine Daten sind sicher.
Ein Problem bleibt jedoch noch offen: Aktuell filtert unsere API zwar die sensiblen Felder heraus, aber immer noch kann jeder anonyme Besucher im Internet einen DELETE /api/bookings/1 Request abfeuern und unsere Buchungen stornieren. Das dürfen natürlich nur Administratoren!
Wie wir unsere Endpunkte mit Symfony Security absichern und Operationen gezielt einschränken, schauen wir uns im nächsten Schritt an.

7. Die Gefahr offener REST-Schnittstellen
Standardmäßig ist API Platform extrem entwicklerfreundlich: Sobald du #[ApiResource] über eine Klasse schreibst, werden alle CRUD-Operationen (Create, Read, Update, Delete) für die ganze Welt freigeschaltet. Für ein lokales Test-Setup ist das super, für eine produktive Contao 5 Headless CMS API ist das ein Sicherheitsrisiko.
Wir müssen API Platform genau mitteilen:
Welche Endpunkte (Operationen) überhaupt existieren sollen.
Wer diese Endpunkte aufrufen darf.
8. Operationen explizit definieren
Wir öffnen unsere Datei src/Entity/Booking.php ein weiteres Mal. Wir nutzen nun den Parameter operations innerhalb des #[ApiResource] Attributs. Um dies zu tun, müssen wir die spezifischen Klassen für die HTTP-Methoden (GET, POST, etc.) importieren.
1<?php
2
3namespace Acme\BeachsideBundle\Entity;
4
5use ApiPlatform\Metadata\ApiResource;
6use ApiPlatform\Metadata\Delete;
7use ApiPlatform\Metadata\Get;
8use ApiPlatform\Metadata\GetCollection;
9use ApiPlatform\Metadata\Post;
10use ApiPlatform\Metadata\Put;
11use Doctrine\DBAL\Types\Types;
12use Doctrine\ORM\Mapping as ORM;
13use Symfony\Component\Serializer\Annotation\Groups;
14
15#[ApiResource(
16 normalizationContext: ['groups' => ['booking:read']],
17 denormalizationContext: ['groups' => ['booking:write']],
18 // NEU: Wir definieren exakt, welche Routen existieren und wer sie nutzen darf
19 operations: [
20 // GET /api/bookings (Öffentlich: Die App muss freie Körbe anzeigen können)
21 new GetCollection(),
22
23 // POST /api/bookings (Öffentlich: Jeder darf einen Korb buchen)
24 new Post(),
25
26 // GET /api/bookings/{id} (Öffentlich: Details einer Buchung ansehen)
27 new Get(),
28
29 // PUT /api/bookings/{id} (GESCHÜTZT: Nur Admins dürfen Buchungen ändern)
30 new Put(security: "is_granted('ROLE_ADMIN')"),
31
32 // DELETE /api/bookings/{id} (GESCHÜTZT: Nur Admins dürfen stornieren)
33 new Delete(security: "is_granted('ROLE_ADMIN')")
34 ]
35)]
36#[ORM\Entity]
37#[ORM\Table(name: 'tl_booking')]
38class Booking
39{
40 // ... Eigenschaften (id, customerName, email, dateStart) ...
41}9. Die Integration mit Symfony Security
Schau dir die Zeile security: "is_granted('ROLE_ADMIN')" genau an. Kommt dir das bekannt vor? Das ist exakt dieselbe Symfony-Security-Logik, die wir in Teil 10 (#[IsGranted('ROLE_ADMIN')]) genutzt haben, um unseren Backend-Controller für das Dashboard abzusichern!
Da das API Platform Framework nativ auf Symfony aufbaut, greift es automatisch auf die Benutzerverwaltung von Contao zu.
Was passiert nun, wenn jemand die API aufruft?
Wenn eine anonyme Smartphone-App einen
GET-Request sendet, liefert die API die Daten aus (da keinsecurity-Parameter gesetzt ist).Wenn ein Hacker versucht, einen
DELETE-Request an/api/bookings/42zu senden, prüft Symfony im Hintergrund den aktuellen User-Kontext. Da der Hacker nicht eingeloggt ist (oder nicht die RolleROLE_ADMINhat), blockiert die API den Request sofort und wirft einen sauberen HTTP-Statuscode403 Forbidden(Zugriff verweigert) zurück.
10. Wie authentifiziert sich ein Frontend? (JWT Basics)
Vielleicht stellst du dir jetzt eine berechtigte Frage: Wie weiß die API eigentlich, ob der Nutzer am Smartphone ein Admin ist? Es gibt in einer App ja keine klassische Contao-Login-Maske mit Session-Cookies!
In der Headless-Welt nutzt man dafür JSON Web Tokens (JWT). Das Prinzip funktioniert so:
Der Administrator öffnet eine spezielle (z. B. in Vue.js geschriebene) Verwaltungs-App auf seinem Tablet.
Er tippt seinen Contao-Benutzernamen und sein Passwort ein. Die App sendet diese per POST an einen speziellen Login-Endpunkt (z. B.
/api/login).Ist das Passwort korrekt, sendet Contao keinen Cookie zurück, sondern einen langen, kryptografischen String: Den JWT.
Die Tablet-App speichert diesen Token. Bei jedem weiteren API-Aufruf (z. B. beim Löschen einer Buchung) hängt die App diesen Token als Stempel an den Request an (
Authorization: Bearer <token>).Symfony liest den Token, erkennt den Admin und gewährt den Zugriff auf das
new Delete().
(Hinweis: Für die Einrichtung von JWT in Contao nutzt man in der Regel das Bundle lexik/jwt-authentication-bundle, was wir hier konzeptionell voraussetzen).
Mit diesem Setup ist deine Contao 5 Headless CMS API nicht nur hochgradig flexibel, sondern auf Enterprise-Niveau abgesichert.

11. Die Grenzen des Standard-ORMs
Bisher macht API Platform alles vollautomatisch: Es nimmt unsere Doctrine Entity (Booking), schaut in die Datenbank und wirft ein JSON aus. Das ist großartig für einfache Textfelder. Aber in einem echten Contao 5 Headless CMS API-Projekt reicht das oft nicht.
Zwei klassische Probleme aus dem Contao-Alltag:
Bilder (UUIDs): Wenn du in Contao ein Bild speicherst, liegt in der Datenbank oft nur eine binäre UUID (z. B.
0x1234...). Eine Smartphone-App kann damit absolut nichts anfangen. Sie braucht eine fertige URL wiehttps://deinedomain.de/assets/images/X/strandkorb.jpg.Berechnete Werte: Der Preis für einen Strandkorb könnte dynamisch sein (Wochenend-Zuschlag, Wetter-Rabatt) und steht nicht statisch in der Datenbank, sondern muss zur Laufzeit (in dem Moment, wo die App anfragt) per PHP berechnet werden.
Um diesen "Bypass" (Umgehung) der Standard-Datenbank-Logik zu bauen, nutzen wir in API Platform 3 das ProviderInterface (den State Provider).
12. Den State Provider erstellen
Ein State Provider ist eine simple PHP-Klasse, die genau eine Aufgabe hat: Sie beschafft die Daten für einen API-Endpunkt. Wenn wir einen eigenen Provider definieren, sagt API Platform: "Okay, ich frage nicht mehr automatisch die Datenbank, sondern ich vertraue blind auf das, was deine Provider-Klasse mir zurückgibt."
Wir fügen unserer Booking-Entity eine neue, virtuelle Eigenschaft $dynamicPrice hinzu (ohne #[ORM\Column], da sie nicht in der Datenbank existiert, aber mit #[Groups(['booking:read'])], damit sie im JSON landet).
Dann erstellen wir unseren eigenen Provider:
Erstelle die Datei /src/BeachsideBundle/src/State/BookingItemProvider.php:
1<?php
2
3namespace Acme\BeachsideBundle\State;
4
5use Acme\BeachsideBundle\Entity\Booking;
6use Acme\BeachsideBundle\Repository\BookingRepository;
7use ApiPlatform\Metadata\Operation;
8use ApiPlatform\State\ProviderInterface;
9
10// Unser eigener Provider implementiert das ProviderInterface von API Platform
11class BookingItemProvider implements ProviderInterface
12{
13 public function __construct(
14 private readonly BookingRepository $bookingRepository
15 // Hier könntest du auch das Contao Image Studio injizieren,
16 // um Bilder on-the-fly zu berechnen!
17 ) {
18 }
19
20 public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
21 {
22 // 1. Die ID aus der aufgerufenen URL holen (z.B. /api/bookings/42)
23 $bookingId = $uriVariables['id'] ?? null;
24
25 if (!$bookingId) {
26 return null;
27 }
28
29 // 2. Den Datensatz aus der Datenbank holen
30 $booking = $this->bookingRepository->find($bookingId);
31
32 if (!$booking) {
33 return null; // API Platform wirft dann automatisch einen 404 Fehler
34 }
35
36 // 3. KOMPLEXE LOGIK AUSFÜHREN (Die Magie passiert hier!)
37 // Wir berechnen z.B. einen dynamischen Preis basierend auf dem aktuellen Datum
38 $basePrice = 15.00;
39 $isWeekend = (new \DateTime())->format('N') >= 6;
40
41 $finalPrice = $isWeekend ? $basePrice + 5.00 : $basePrice;
42
43 // Wir setzen den berechneten Wert in unsere Entität
44 $booking->setDynamicPrice($finalPrice);
45
46 // 4. Das modifizierte Objekt an die API zurückgeben
47 return $booking;
48 }
49}13. Den Provider mit der Entity verknüpfen
Jetzt müssen wir API Platform nur noch mitteilen, dass es für das Auslesen einer einzelnen Buchung (Get) unseren neuen Provider nutzen soll, anstatt den Standard-Weg zu gehen.
Wir öffnen unsere Booking.php Entity und passen die Get-Operation an:
1<?php
2
3namespace Acme\BeachsideBundle\Entity;
4
5use Acme\BeachsideBundle\State\BookingItemProvider; // <-- NEU
6use ApiPlatform\Metadata\ApiResource;
7use ApiPlatform\Metadata\Get;
8// ... andere Imports ...
9
10#[ApiResource(
11 normalizationContext: ['groups' => ['booking:read']],
12 operations: [
13 // ... Post, Delete etc.
14
15 // NEU: Wir weisen API Platform an, für diesen Endpunkt unseren Custom Provider zu nutzen!
16 new Get(provider: BookingItemProvider::class),
17 ]
18)]
19#[ORM\Entity]
20class Booking
21{
22 // ... bisherige Eigenschaften ...
23
24 // Eine "virtuelle" Eigenschaft (nicht in der DB gespeichert!)
25 #[Groups(['booking:read'])]
26 private ?float $dynamicPrice = null;
27
28 public function getDynamicPrice(): ?float
29 {
30 return $this->dynamicPrice;
31 }
32
33 public function setDynamicPrice(?float $dynamicPrice): self
34 {
35 $this->dynamicPrice = $dynamicPrice;
36 return $this;
37 }
38}Das Resultat: Maximale Flexibilität
Wenn du jetzt in deiner Smartphone-App den Request GET /api/bookings/42 abfeuerst, durchläuft Contao deinen eigenen BookingItemProvider. Du erhältst im JSON-Output nun das Feld "dynamicPrice": 20.0.
Mit diesem Konzept kannst du alles aus Contao in deine Headless-Applikation exportieren. Du könntest einen Provider schreiben, der alte tl_news Artikel aus den klassischen Contao-Models (NewsModel::findAll()) ausliest, die Contao-Insert-Tags ({{link_url::*}}) parst und als sauberes JSON an ein Vue.js-Frontend schickt.
State Provider sind die Brücke zwischen dem klassischen Contao-Core und der modernen API-First-Welt!

14. Das Frontend: Die andere Seite des Kabels
Erinnern wir uns an die PlayStation-Metapher: Contao ist jetzt unsere Konsole, die API ist das HDMI-Kabel. Wir brauchen nun einen Fernseher – das Frontend.
In der modernen Webentwicklung nutzt man dafür häufig sogenannte Single Page Applications (SPA) oder Frameworks für Server-Side-Rendering (SSR) wie Vue.js (Nuxt.js) oder React (Next.js). Diese Frameworks laufen völlig autark, oft sogar auf einem komplett anderen Server als Contao. Sie wissen nichts von Twig, DCA oder PHP. Sie verstehen nur eines: JSON.
15. Daten abrufen (Beispiel mit Vue.js / Nuxt 3)
Lass uns ein stark vereinfachtes Beispiel ansehen, wie ein Entwickler in einer Nuxt 3 App unsere Strandkorb-Buchungen aus der Contao 5 Headless CMS API abruft und auf dem Bildschirm rendert.
Das Frontend-Script macht einen einfachen HTTP GET Request an unsere API-URL:
1<template>
2 <div>
3 <h1>Aktuelle Strandkorb-Buchungen</h1>
4
5 <p v-if="pending">Daten werden aus Contao geladen...</p>
6
7 <ul v-else>
8 <li v-for="booking in bookings['hydra:member']" :key="booking.id">
9 Kunde: {{ booking.customerName }} <br>
10 Datum: {{ booking.dateStart }} <br>
11 Preis: {{ booking.dynamicPrice }} €
12 </li>
13 </ul>
14 </div>
15</template>
16
17<script setup>
18// Nuxt 3 Fetch-Funktion ruft unsere Contao API auf
19// Das Ergebnis wird reaktiv in der Variable 'bookings' gespeichert
20const { data: bookings, pending } = await useFetch('https://api.dein-strandkorb.de/api/bookings', {
21 headers: {
22 // Falls wir geschützte Daten abrufen würden, käme hier unser JWT-Token rein:
23 // 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...'
24 }
25})
26</script>Achte auf das Feld bookings['hydra:member']. API Platform nutzt standardmäßig das Format JSON-LD (verbunden mit dem Hydra-Vokabular). Das bedeutet, dass die API nicht nur rohe Daten liefert, sondern auch Metadaten: Wie viele Seiten gibt es insgesamt? Wo ist der Link zur nächsten Seite? Das macht die Paginierung im Frontend extrem einfach.
16. CORS: Der Endgegner der API-Entwicklung
Wenn du den obigen Vue.js-Code lokal testest (dein Frontend läuft z.B. auf http://localhost:3000 und dein Contao auf http://localhost:8000), wirst du im Browser eine rote Fehlermeldung sehen, die fast jeden API-Anfänger zur Verzweiflung treibt:
Access to fetch at '...' from origin '...' has been blocked by CORS policy.
Was ist CORS (Cross-Origin Resource Sharing)? Das ist ein Sicherheitsmechanismus moderner Webbrowser. Ein Browser verbietet es einem JavaScript-Programm, das auf Domain A (Frontend) läuft, Daten von Domain B (Contao Backend) abzurufen – es sei denn, Domain B erlaubt dies ausdrücklich!
Da wir API Platform nutzen, ist die Lösung bereits in Contao vorinstalliert: Das NelmioCorsBundle. Wir müssen Contao nur konfigurieren, damit es unserem Vue.js-Frontend vertraut.
Erstelle oder öffne die Datei config/packages/nelmio_cors.yaml in deiner Contao-Installation:
1nelmio_cors:
2 defaults:
3 origin_regex: true
4 allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
5 allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
6 allow_headers: ['Content-Type', 'Authorization']
7 expose_headers: ['Link']
8 max_age: 3600
9 paths:
10 # Wir erlauben CORS explizit für unsere API-Routen!
11 '^/api/':
12 allow_origin: ['*'] # Für Entwicklung: Erlaubt ALLE Frontends (Vorsicht im Live-Betrieb!)
13 allow_headers: ['Content-Type', 'Authorization']
14 allow_methods: ['POST', 'PUT', 'GET', 'DELETE']
15 max_age: 3600Wichtiger Hinweis für den Live-Betrieb: Ersetze ['*'] durch die exakte Domain deines Frontends (z. B. ['https://mein-strandkorb-frontend.de']), damit nicht fremde Webseiten deine API über den Browser ihrer Nutzer anzapfen können.
Wenn du den Cache leerst, sendet Contao die korrekten HTTP-Header (Access-Control-Allow-Origin) zurück. Dein Vue.js-Frontend darf die Daten nun lesen und anzeigen!

17. Best Practices für deine Headless Architektur
Wenn du eine Contao 5 Headless CMS API produktiv betreibst, gelten andere Spielregeln als bei einer klassischen Website. Deine API ist das Fundament für potenziell unzählige externe Frontends. Beachte daher diese Best Practices:
Caching ist Pflicht: JSON-Antworten lassen sich hervorragend cachen. API Platform bringt eine native Integration für HTTP-Cache-Header und Systeme wie Varnish mit. Wenn sich an deinen Strandkorb-Buchungen nichts ändert, sollte die API die Daten aus dem Cache im Millisekunden-Bereich ausliefern, ohne die Contao-Datenbank überhaupt zu berühren.
Das N+1 Query Problem vermeiden: Wenn du Listen über die API abrufst (z.B. alle Buchungen inklusive der verknüpften Kundendaten), feuert Doctrine im Hintergrund oft für jeden einzelnen Datensatz eine neue Datenbankabfrage ab. Nutze in API Platform sogenannte DataProvider oder Doctrine Extensions, um Tabellen-Joins (
LEFT JOIN) sauber aufzulösen.Rate Limiting aktivieren: Da APIs oft von automatisierten Skripten (oder böswilligen Bots) aufgerufen werden, solltest du in Symfony das Rate Limiting konfigurieren. Erlaube z.B. maximal 60 API-Aufrufe pro Minute pro IP-Adresse, um deinen Server vor Überlastung zu schützen.

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
Die Verwandlung von Contao in ein modernes Headless CMS ist ein massiver Architektur-Boost für jedes professionelle Webprojekt.
Wir haben in diesem Artikel gelernt:
API Platform Integration: Wie man das mächtigste API-Tool des Symfony-Ökosystems nahtlos in Contao 5 einbindet.
Entity Freigabe: Wie ein einziges PHP-Attribut (
#[ApiResource]) aus einer Doctrine-Klasse eine voll funktionsfähige REST-API inklusive Swagger-Dokumentation generiert.Datenschutz & Sicherheit: Wie Serialization Groups Daten filtern und wir Endpunkte über rollenbasierte Zugriffe (
is_granted) gegen unbefugte Manipulationen abdichten.Custom State Provider: Wie wir die klassische Datenbank-Logik umgehen, um hochkomplexe oder on-the-fly berechnete Daten an unsere Apps auszuliefern.
Dein Backend ist nun vollständig vom Frontend entkoppelt und bereit, Daten an Smartphones, Smartwatches oder moderne JavaScript-Frameworks zu senden.
Wie geht es weiter?
Unsere Applikation funktioniert technisch einwandfrei, aber für den produktiven Live-Betrieb müssen wir das System jetzt auf maximale Geschwindigkeit trimmen und für internationale Nutzer vorbereiten.
Nächster Teil: Tech-Stack: HttpCache, Translator, XLIFF. [Zum Artikel: Performance, Caching & Release]

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.


