
Contao 5 Doctrine Entities: Moderne Datenmodellierung statt SQL-Chaos

Wer heute professionelle Erweiterungen schreibt, kommt an Contao 5 Doctrine Entities nicht vorbei. In Teil 1 haben wir das Fundament gegossen. Jetzt ziehen wir die Wände hoch. Wenn du schon länger mit Contao arbeitest, kennst du sicher die database.sql. Eine Datei, in der man händisch SQL-Befehle schreibt (CREATE TABLE...).
Die harte Wahrheit: Die manuelle SQL-Verwaltung ist tot. Der Einsatz von Contao 5 Doctrine Entities ist der einzig wahre Weg für moderne Contao-Entwicklung. Warum? Weil wir nicht mehr in flachen Tabellen denken, sondern in Objekten.
In diesem Teil nutzen wir Contao 5 Doctrine Entities, um unseren "Strandkorb" als intelligente PHP-Klasse zu modellieren. Wir setzen auf Doctrine ORM und PHP 8 Attribute. Das macht unseren Code sauberer, sicherer und unabhängig vom Datenbank-Typ.
Das ist der Plan:
Verstehen, warum Contao 5 Doctrine Entities besser sind als Arrays.
Die
BeachChairEntity erstellen.Das Repository einrichten.
Die Datenbank mittels Migration aktualisieren (ohne SQL zu schreiben!).
1. Die Entity: Dein Strandkorb als Objekt
Früher (in Contao 3/4) war ein Datensatz oft nur ein assoziatives Array: $arrStrandkorb['title']. Das Problem: Du weißt nie, welche Keys existieren. Tippfehler führen zu Bugs.
Mit Contao 5 Doctrine Entities ist ein Strandkorb eine Klasse. Sie hat Eigenschaften und Methoden. Die IDE hilft dir beim Autocomplete.

Erstelle den Ordner: /src/BeachsideBundle/src/Entity. Erstelle darin die Datei: BeachChair.php.
PHP
1<?php
2
3namespace Acme\BeachsideBundle\Entity;
4
5use Acme\BeachsideBundle\Repository\BeachChairRepository;
6use Doctrine\DBAL\Types\Types;
7use Doctrine\ORM\Mapping as ORM;
8
9#[ORM\Entity(repositoryClass: BeachChairRepository::class)]
10#[ORM\Table(name: "tl_beach_chair")]
11class BeachChair
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 #[ORM\Column(length: 255)]
22 private ?string $title = null;
23
24 #[ORM\Column(length: 10, unique: true)]
25 private ?string $chairNumber = null;
26
27 // Preis in Cent speichern (Best Practice um Rundungsfehler zu vermeiden!)
28 #[ORM\Column(type: Types::INTEGER)]
29 private int $pricePerDay = 0;
30
31 #[ORM\Column(type: Types::TEXT, nullable: true)]
32 private ?string $description = null;
33
34 // Getter und Setter
35 public function getId(): ?int
36 {
37 return $this->id;
38 }
39
40 public function getTstamp(): int
41 {
42 return $this->tstamp;
43 }
44
45 public function setTstamp(int $tstamp): static
46 {
47 $this->tstamp = $tstamp;
48 return $this;
49 }
50
51 public function getTitle(): ?string
52 {
53 return $this->title;
54 }
55
56 public function setTitle(string $title): static
57 {
58 $this->title = $title;
59 return $this;
60 }
61
62 public function getChairNumber(): ?string
63 {
64 return $this->chairNumber;
65 }
66
67 public function setChairNumber(string $chairNumber): static
68 {
69 $this->chairNumber = $chairNumber;
70 return $this;
71 }
72
73 public function getPricePerDay(): int
74 {
75 return $this->pricePerDay;
76 }
77
78 public function setPricePerDay(int $pricePerDay): static
79 {
80 $this->pricePerDay = $pricePerDay;
81 return $this;
82 }
83
84 public function getDescription(): ?string
85 {
86 return $this->description;
87 }
88
89 public function setDescription(?string $description): static
90 {
91 $this->description = $description;
92 return $this;
93 }
94}Analyse des Codes:
#[ORM\Table(name: "tl_beach_chair")]: Wir nutzen den Präfixtl_. Das ist extrem wichtig, damit Contao später im Backend (DCA) automatisch damit arbeiten kann.PHP 8 Attribute: Statt langer Kommentare (Annotations) nutzen wir
#[...]. Das ist performanter und lesbarer.Typisierung: Wir wissen genau:
pricePerDayist einint(Cent), keinfloat.
2. Das Repository: Der Daten-Manager
Wo holen wir die Daten her? Im Repository. Während die Entity die Datenstruktur ist, ist das Repository der Wächter, der Datenbank-Abfragen macht.
Erstelle den Ordner: /src/BeachsideBundle/src/Repository. Erstelle darin: BeachChairRepository.php.
PHP
1<?php
2
3namespace Acme\BeachsideBundle\Repository;
4
5use Acme\BeachsideBundle\Entity\BeachChair;
6use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
7use Doctrine\Persistence\ManagerRegistry;
8
9/**
10 * @extends ServiceEntityRepository<BeachChair>
11 */
12class BeachChairRepository extends ServiceEntityRepository
13{
14 public function __construct(ManagerRegistry $registry)
15 {
16 parent::__construct($registry, BeachChair::class);
17 }
18
19 public function save(BeachChair $entity, bool $flush = false): void
20 {
21 $this->getEntityManager()->persist($entity);
22
23 if ($flush) {
24 $this->getEntityManager()->flush();
25 }
26 }
27
28 public function remove(BeachChair $entity, bool $flush = false): void
29 {
30 $this->getEntityManager()->remove($entity);
31
32 if ($flush) {
33 $this->getEntityManager()->flush();
34 }
35 }
36
37 // Später fügen wir hier Methoden hinzu wie:
38 // public function findAvailableChairs($startDate, $endDate) { ... }
39}Profi-Tipp: Durch extends ServiceEntityRepository bekommen wir Methoden wie find(), findAll() oder findOneBy() geschenkt. Wir müssen kein SQL schreiben, um einen Strandkorb anhand der ID zu finden.
3. Konfiguration: Mapping aktivieren
Standardmäßig scannt Doctrine in Symfony-Apps automatisch src/Entity. Da wir uns in einem Bundle befinden, müssen wir sicherstellen, dass Doctrine unsere Klassen findet.
Wenn du die Standard-Struktur aus Teil 1 eingehalten hast, sollte es "out of the box" funktionieren. Falls nicht, müssen wir der BeachsideExtension.php noch sagen, dass sie Doctrine konfigurieren soll. Aber wir probieren es erst einmal ohne (der moderne "Auto-Mapping" Weg).
4. Die Migration: Vom Code zur Datenbank
Jetzt kommt der magische Moment. Wir haben PHP-Code geschrieben, aber unsere Datenbank ist noch leer. Vergiss phpMyAdmin. Vergiss CREATE TABLE.
Öffne dein Terminal und führe folgenden Befehl im Root-Verzeichnis aus:
vendor/bin/contao-console doctrine:schema:update --dump-sql
Was passiert jetzt? Doctrine vergleicht deine PHP-Klasse BeachChair mit der echten Datenbank. Es merkt: "Huch, die Tabelle tl_beach_chair fehlt!" und generiert dir das SQL:
SQL
CREATE TABLE tl_beach_chair (id INT AUTO_INCREMENT NOT NULL, tstamp INT UNSIGNED DEFAULT 0 NOT NULL, title VARCHAR(255) NOT NULL, ... PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 ...;Wenn das SQL gut aussieht, feuern wir es ab:
vendor/bin/contao-console doctrine:schema:update --force(Hinweis: In Produktionsumgebungen nutzen wir doctrine:migrations:diff und migrate, aber für die Entwicklung ist schema:update --force der schnelle Weg.)
5. Check: Ist die Tabelle da?
Du kannst jetzt in deine Datenbank schauen (ja, jetzt darfst du phpMyAdmin oder Adminer kurz öffnen). Du wirst sehen: Die Tabelle tl_beach_chair existiert. Sauber definiert, mit den richtigen Indizes. Und das alles, ohne eine Zeile SQL geschrieben zu haben.
Teil der Serie
Contao 5 Masterclass: The Beachside Project
Häufig gestellte Fragen (FAQ)
Zusammenfassung & Ausblick
Du hast den zweiten Schritt der Contao 5 Doctrine Entities gemeistert.
Du hast eine PHP-Klasse erstellt, die eine Datenbanktabelle repräsentiert.
Du hast ein Repository angelegt.
Du hast die Datenbank per Konsole aktualisiert.
Aktuell ist die Datenbank leer und wir haben keine Oberfläche, um Strandkörbe anzulegen. Wir könnten das jetzt per Code machen, aber wir wollen ja, dass unser Kunde (der Vermieter in Binz) das bequem im Contao Backend erledigen kann.
Dafür brauchen wir das DCA (Data Container Array). Im nächsten Teil verheiraten wir unsere moderne Entity mit dem klassischen Contao Backend.
Nächster Artikel: [Teil 3 – Das Backend: Perfekte Eingabemasken (DCA)]

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.


