
Contao 5 Doctrine Relations: Wir bauen die Buchungs-Logik

Ohne saubere Contao 5 Doctrine Relations ist dein Datenmodell nur eine flache Liste. Echte Business-Applikationen entstehen erst, wenn Daten miteinander interagieren. Ein Strandkorb, der nur isoliert in der Datenbank steht, verdient kein Geld. Er muss gebucht werden. Dafür brauchen wir eine zweite Entity: Booking.
Das Geheimnis professioneller Contao-Entwicklung liegt in der korrekten Nutzung von Contao 5 Doctrine Relations. Wir müssen die Buchung fest mit dem Strandkorb verdrahten ("Dieser Strandkorb gehört zu dieser Buchung"). In der alten SQL-Welt musstest du manuell IDs speichern und Joins schreiben. Mühsam. Mit Contao 5 Doctrine Relations (Assoziationen) passiert das auf Objektebene. Du sagst einfach: $booking->setBeachChair($chair). Den Rest macht Doctrine.
Das ist der Plan für deine Contao 5 Doctrine Relations:
Das Konzept verstehen (One-to-Many).
Die
BookingEntity erstellen.Die
BeachChairEntity für die Beziehung fit machen.Datenbank-Update (Migration).
Das DCA für Buchungen bauen (inklusive Foreign-Key-Magie!).
1. Die Theorie: Was sind Contao 5 Doctrine Relations?
Wir brauchen eine 1-zu-n Beziehung (One-to-Many).
One: Ein Strandkorb (
BeachChair) ...Many: ... kann viele Buchungen (
Booking) haben.
Andersrum: Eine Buchung gehört immer zu genau einem Strandkorb (Many-to-One). Das ist der Standardfall für Contao 5 Doctrine Relations.
2. Die neue Entity: Booking.php
Wir erstellen /src/BeachsideBundle/src/Entity/Booking.php. Hier definieren wir den Zeitraum und – ganz wichtig – die Verbindung zum Strandkorb.
Hier definieren wir den Zeitraum und – ganz wichtig für unsere Contao 5 Doctrine Relations – die Verbindung zum Strandkorb.

Profi-Tipp für Contao: Auch wenn Symfony DateTime liebt, speichern wir Start- und Enddatum hier als INTEGER (Unix Timestamp). Warum? Weil der Standard-Datepicker von Contao (rgxp => date) Timestamps liefert. Das spart uns im DCA massiv Ärger.
1<?php
2
3namespace Acme\BeachsideBundle\Entity;
4
5use Acme\BeachsideBundle\Repository\BookingRepository;
6use Doctrine\DBAL\Types\Types;
7use Doctrine\ORM\Mapping as ORM;
8
9#[ORM\Entity(repositoryClass: BookingRepository::class)]
10#[ORM\Table(name: "tl_booking")]
11class Booking
12{
13 #[ORM\Id]
14 #[ORM\GeneratedValue]
15 #[ORM\Column]
16 private ?int $id = null;
17
18 #[ORM\Column(type: Types::INTEGER, options: ['unsigned' => true, 'default' => 0])]
19 private int $tstamp = 0;
20
21 // Startdatum als Unix Timestamp
22 #[ORM\Column(type: Types::INTEGER)]
23 private int $dateStart = 0;
24
25 // Enddatum als Unix Timestamp
26 #[ORM\Column(type: Types::INTEGER)]
27 private int $dateEnd = 0;
28
29 #[ORM\Column(length: 255)]
30 private ?string $customerName = null;
31
32 // --- HIER IST DIE RELATION ---
33 #[ORM\ManyToOne(inversedBy: 'bookings')]
34 #[ORM\JoinColumn(nullable: false)]
35 private ?BeachChair $beachChair = null;
36
37 // Getter & Setter
38
39 public function getId(): ?int
40 {
41 return $this->id;
42 }
43
44 public function getDateStart(): int
45 {
46 return $this->dateStart;
47 }
48
49 public function setDateStart(int $dateStart): static
50 {
51 $this->dateStart = $dateStart;
52 return $this;
53 }
54
55 public function getDateEnd(): int
56 {
57 return $this->dateEnd;
58 }
59
60 public function setDateEnd(int $dateEnd): static
61 {
62 $this->dateEnd = $dateEnd;
63 return $this;
64 }
65
66 public function getCustomerName(): ?string
67 {
68 return $this->customerName;
69 }
70
71 public function setCustomerName(string $customerName): static
72 {
73 $this->customerName = $customerName;
74 return $this;
75 }
76
77 // Relation Getter/Setter
78 public function getBeachChair(): ?BeachChair
79 {
80 return $this->beachChair;
81 }
82
83 public function setBeachChair(?BeachChair $beachChair): static
84 {
85 $this->beachChair = $beachChair;
86 return $this;
87 }
88}Vergiss nicht das Repository! Erstelle parallel dazu /src/BeachsideBundle/src/Repository/BookingRepository.php (genau wie in Teil 2, nur mit Booking::class).
3. Die Gegenseite: BeachChair updaten
Damit die Contao 5 Doctrine Relations bidirektional funktionieren (d.h. damit wir sagen können: $chair->getBookings()), müssen wir die BeachChair.php Entity anpassen.
Öffne /src/BeachsideBundle/src/Entity/BeachChair.php und füge hinzu:
1// Oben bei den Uses hinzufügen:
2use Doctrine\Common\Collections\ArrayCollection;
3use Doctrine\Common\Collections\Collection;
4
5// In der Klasse hinzufügen:
6 #[ORM\OneToMany(mappedBy: 'beachChair', targetEntity: Booking::class)]
7 private Collection $bookings;
8
9 public function __construct()
10 {
11 $this->bookings = new ArrayCollection();
12 }
13
14 /**
15 * @return Collection<int, Booking>
16 */
17 public function getBookings(): Collection
18 {
19 return $this->bookings;
20 }Das ist sauberer Code! Wir initialisieren die Relation als leere ArrayCollection.
4. Migration: Die Hochzeit der Tabellen
Jetzt sagen wir der Datenbank, dass die beiden Tabellen verheiratet sind. Führe im Terminal aus:
vendor/bin/contao-console doctrine:schema:update --dump-sqlDu wirst sehen, dass Doctrine eine neue Tabelle tl_booking anlegt UND (das ist das Wichtige) einen Foreign Key Constraint (IDX_...) hinzufügt. Das garantiert technische Referenz-Integrität.
vendor/bin/contao-console doctrine:schema:update --force5. Das DCA für Buchungen (Foreign Keys nutzen)
Jetzt brauchen wir eine Eingabemaske für Buchungen, in der wir den Strandkorb auswählen können. Erstelle /src/BeachsideBundle/contao/dca/tl_booking.php.

Wir nutzen hier ein "Killer-Feature" von Contao: Die direkte Verknüpfung via foreignKey.
1<?php
2
3use Contao\DC_Table;
4
5$GLOBALS['TL_DCA']['tl_booking'] = [
6 'config' => [
7 'dataContainer' => DC_Table::class,
8 'sql' => [
9 'keys' => ['id' => 'primary'],
10 ],
11 ],
12 'list' => [
13 'sorting' => [
14 'mode' => 2, // Sortierung nach einem Feld mit Header
15 'fields' => ['beachChair'], // Gruppiert nach Strandkorb
16 'flag' => 1,
17 'panelLayout' => 'filter;search,limit',
18 ],
19 'label' => [
20 'fields' => ['customerName', 'dateStart', 'dateEnd'],
21 'showColumns' => true,
22 'format' => '%s (%s - %s)', // Formatierung folgt unten
23 ],
24 'operations' => [
25 'edit' => ['href' => 'act=edit', 'icon' => 'edit.svg'],
26 'delete' => ['href' => 'act=delete', 'icon' => 'delete.svg'],
27 ],
28 ],
29 'palettes' => [
30 'default' => 'beachChair,customerName;{date_legend},dateStart,dateEnd',
31 ],
32 'fields' => [
33 'id' => ['sql' => "int(10) unsigned NOT NULL auto_increment"],
34 'tstamp' => ['sql' => "int(10) unsigned NOT NULL default '0'"],
35
36 // HIER IST DIE RELATION IM BACKEND
37 'beachChair' => [
38 'inputType' => 'select',
39 'filter' => true,
40 'sorting' => true,
41 // Magic: Tabelle.Feld -> Contao holt automatisch die Optionen!
42 'foreignKey' => 'tl_beach_chair.chairNumber',
43 'eval' => ['mandatory' => true, 'includeBlankOption' => true, 'tl_class' => 'w50'],
44 'relation' => ['type' => 'belongsTo', 'load' => 'lazy'],
45 ],
46
47 'customerName' => [
48 'inputType' => 'text',
49 'search' => true,
50 'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'],
51 ],
52 'dateStart' => [
53 'inputType' => 'text',
54 'eval' => ['mandatory' => true, 'rgxp' => 'date', 'datepicker' => true, 'tl_class' => 'w50 wizard'],
55 ],
56 'dateEnd' => [
57 'inputType' => 'text',
58 'eval' => ['mandatory' => true, 'rgxp' => 'date', 'datepicker' => true, 'tl_class' => 'w50 wizard'],
59 ],
60 ],
61];Was ist hier passiert?
foreignKey: Mit'tl_beach_chair.chairNumber'sagen wir Contao: "Fülle dieses Select-Menü mit allen Einträgen aus der Tabelletl_beach_chair. Zeige als Label den Wert vonchairNumberan und speichere die ID."relation: Diese Zeile hilft Contao (und Export-Tools) zu verstehen, wie die Daten zusammenhängen.
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 Contao 5 Doctrine Relations erfolgreich implementiert.
Die
BookingEntity referenziertBeachChair.Die Datenbank sichert die Verbindung (Foreign Keys).
Im Backend kannst du jetzt Buchungen anlegen und Strandkörbe per Dropdown auswählen.
Registriere noch die Tabelle tl_booking in deiner config.php (wie in Teil 3), leere den Cache und probiere es aus!
Das Problem: Aktuell verhindert niemand, dass ich Strandkorb 1 vom 01.08. bis 05.08. buche, obwohl er da schon belegt ist. Das ist ein Job für echte Business-Logik. Im nächsten Teil verlassen wir die Konfiguration und programmieren einen intelligenten Service.
Nächster Artikel: Business-Logik & Services programmieren

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.


