Den Lifecycle von Doctrine-Objekten verwalten
Beim Erstellen eines neuen Kommentars wäre es gut, wenn das createdAt
-Datum automatisch auf das aktuelle Datum und die aktuelle Uhrzeit gesetzt würde.
Doctrine hat verschiedene Möglichkeiten, Objekte und deren Properties (Eigenschaften) während ihres Lifecycle zu manipulieren (bevor die Zeile in der Datenbank erstellt wird, nachdem die Zeile aktualisiert wird, ...).
Lifecycle-Callbacks definieren
Wenn das Verhalten nicht von einem Service abhängt und nur auf eine bestimmte Entity angewendet werden soll, definierst Du einen Callback in der Entity-Klasse:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
--- a/src/Entity/Comment.php
+++ b/src/Entity/Comment.php
@@ -6,6 +6,7 @@ use App\Repository\CommentRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CommentRepository::class)]
+#[ORM\HasLifecycleCallbacks]
class Comment
{
#[ORM\Id]
@@ -90,6 +91,12 @@ class Comment
return $this;
}
+ #[ORM\PrePersist]
+ public function setCreatedAtValue()
+ {
+ $this->createdAt = new \DateTimeImmutable();
+ }
+
public function getConference(): ?Conference
{
return $this->conference;
Das ORM\PrePersist
-Event wird ausgelöst, wenn das Objekt zum ersten Mal in der Datenbank gespeichert wird. In diesem Fall wird die setCreatedAtValue()
-Methode aufgerufen und das aktuelle Datum und die aktuelle Uhrzeit für den Wert der createdAt
-Property/Spalte verwendet.
Slugs zu Konferenzen hinzufügen
Die URLs für Konferenzen sind nicht aussagekräftig: /conference/1
. Noch wichtiger ist, dass sie von einem Implementierungsdetail abhängen (der Primärschlüssel der Datenbank wird veröffentlicht).
Wie sieht es mit der Verwendung von URLs wie /conference/paris-2020
aus? Das würde viel besser aussehen. Wir nennen paris-2020
den Konferenz-Slug.
Füge ein neues slug
-Property für Konferenzen hinzu (eine Zeichenkette mit 255 Zeichen, die nicht leer sein darf):
1
$ symfony console make:entity Conference
Erstelle eine Migration, um die neue Spalte hinzuzufügen:
1
$ symfony console make:migration
Und führe diese neue Migration aus:
1
$ symfony console doctrine:migrations:migrate
Bekommst Du einen Fehler? Das war zu erwarten. Warum? Weil wir festgelegt haben, dass der Slug nicht null
(leer) sein darf, aber bestehende Einträge in der Konferenzdatenbank werden beim Ausführen der Migration einen null
-Wert erhalten. Lass uns das beheben, indem wir die Migration verbessern:
1 2 3 4 5 6 7 8 9 10 11 12 13
--- a/migrations/Version00000000000000.php
+++ b/migrations/Version00000000000000.php
@@ -20,7 +20,9 @@ final class Version00000000000000 extends AbstractMigration
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
- $this->addSql('ALTER TABLE conference ADD slug VARCHAR(255) NOT NULL');
+ $this->addSql('ALTER TABLE conference ADD slug VARCHAR(255)');
+ $this->addSql("UPDATE conference SET slug=CONCAT(LOWER(city), '-', year)");
+ $this->addSql('ALTER TABLE conference ALTER COLUMN slug SET NOT NULL');
}
public function down(Schema $schema): void
Der Trick hier ist, die Spalte hinzuzufügen und dabei null
-Werte zuzulassen, anschließend den Slug zu setzen und schließlich die Slug-Spalte so zu ändern, dass sie null
nicht erlaubt.
Note
Für ein echtes Projekt ist die Verwendung CONCAT(LOWER(city), '-', year)
möglicherweise nicht ausreichend. In diesem Fall müssten wir den "echten" Slugger verwenden.
Die Migration sollte jetzt fehlerfrei laufen:
1
$ symfony console doctrine:migrations:migrate
Da die Anwendung bald Slugs verwenden wird, um jede Konferenz zu finden, sollten wir die Konferenz-Entity verbessern, um sicherzustellen, dass die Slug-Werte in der Datenbank eindeutig sind:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
--- a/src/Entity/Conference.php
+++ b/src/Entity/Conference.php
@@ -6,8 +6,10 @@ use App\Repository\ConferenceRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
#[ORM\Entity(repositoryClass: ConferenceRepository::class)]
+#[UniqueEntity('slug')]
class Conference
{
#[ORM\Id]
@@ -27,7 +29,7 @@ class Conference
#[ORM\OneToMany(mappedBy: 'conference', targetEntity: Comment::class, orphanRemoval: true)]
private $comments;
- #[ORM\Column(type: 'string', length: 255)]
+ #[ORM\Column(type: 'string', length: 255, unique: true)]
private $slug;
public function __construct()
Wie du vielleicht schon erraten hast, müssen wir den Migrationstanz aufführen:
1
$ symfony console make:migration
1
$ symfony console doctrine:migrations:migrate
Slugs generieren
Das Erzeugen eines Slug, der in einer URL gut lesbar ist (wobei alles außer ASCII-Zeichen kodiert werden sollte), ist eine schwierige Aufgabe, insbesondere für andere Sprachen als Englisch. Wie konvertiert man é
zum Beispiel zu e
?
Anstatt das Rad neu zu erfinden, verwenden wir die Symfony-Komponente String
, die die Manipulation von Zeichenketten erleichtert und einen Slugger bietet.
Füge in der Conference
-Klasse eine computeSlug()
-Methode hinzu, die den Slug basierend auf den Konferenzdaten erstellt:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
--- a/src/Entity/Conference.php
+++ b/src/Entity/Conference.php
@@ -7,6 +7,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\String\Slugger\SluggerInterface;
#[ORM\Entity(repositoryClass: ConferenceRepository::class)]
#[UniqueEntity('slug')]
@@ -47,6 +48,13 @@ class Conference
return $this->id;
}
+ public function computeSlug(SluggerInterface $slugger)
+ {
+ if (!$this->slug || '-' === $this->slug) {
+ $this->slug = (string) $slugger->slug((string) $this)->lower();
+ }
+ }
+
public function getCity(): ?string
{
return $this->city;
Die computeSlug()
-Methode erstellt einen Slug nur, wenn der aktuelle Slug leer ist oder auf den speziellen -
-Wert eingestellt ist. Warum brauchen wir den besonderen -
-Wert? Beim Hinzufügen einer Konferenz im Backend wird der Slug benötigt. Wir benötigen also einen nicht-leeren Wert, der der Anwendung mitteilt, dass wird den Slug automatisch generieren lassen möchten.
Einen komplexen Lifecycle-Callback definieren
Wie bei der createdAt
-Property, soll der slug` jedesmal automatisch durch den Aufruf der
computeSlug()``-Methode aktualisiert werden, wenn die Konferenz geändert wird.
Da diese Methode jedoch von einer SluggerInterface
-Implementierung abhängt, können wir kein prePersist
-Event wie bisher hinzufügen (wir haben keine Möglichkeit, den Slugger zu injizieren).
Erstelle stattdessen einen Doctrine Entity Listener:
Beachte, dass der Slug aktualisiert wird, wenn eine neue Konferenz erstellt wird (prePersist()
) und wenn sie aktualisiert wird (preUpdate()
).
Einen Service im Container konfigurieren
Bisher haben wir noch nicht über eine Schlüsselkomponente von Symfony gesprochen, den Dependency Injection Container. Der Container ist für die Verwaltung der Services verantwortlich: Er erstellt und injiziert sie bei Bedarf.
Ein Service ist ein "globales" Objekt, das Funktionen bereitstellt, z. B. einen Mailer, einen Logger, einen Slugger, etc. (im Gegensatz zu Datenobjekten wie z. B. Doctrine Entity Instanzen).
Du interagierst selten direkt mit dem Container, da er automatisch Service-Objekte injiziert, wann immer Du sie benötigst: Der Container injiziert beispielsweise die Controller-Objektargumente, wenn Du sie mit Type-Hints (Typen-Hinweise) deklarierst.
Wenn Du dich gefragt hast, wie der Event-Listener im vorherigen Schritt registriert wurde, hast Du nun die Antwort: der Container. Wenn eine Klasse bestimmte Interfaces implementiert, weiß der Container, dass die Klasse auf eine bestimmte Weise registriert werden muss.
Leider ist die Automatisierung nicht für alles vorgesehen, insbesondere nicht für Pakete von Drittanbietern. Der Entity-Listener, den wir gerade geschrieben haben, ist ein Beispiel dafür; er kann nicht automatisch vom Symfony Service-Container verwaltet werden, da er kein Interface implementiert und keine dem Container bekannte Klasse erweitert.
Wir müssen den Listener im Container teilweise deklarieren. Die Dependency-Verknüpfung kann weggelassen werden, da sie noch vom Container erraten werden kann, aber wir müssen manuell einige Tags hinzufügen, um den Listener beim Doctrine Event-Dispatcher zu registrieren:
1 2 3 4 5 6 7 8 9 10
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -22,3 +22,7 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
+ App\EntityListener\ConferenceEntityListener:
+ tags:
+ - { name: 'doctrine.orm.entity_listener', event: 'prePersist', entity: 'App\Entity\Conference'}
+ - { name: 'doctrine.orm.entity_listener', event: 'preUpdate', entity: 'App\Entity\Conference'}
Note
Verwechsel die Listener von Doctrine Events nicht mit denen von Symfony. Auch wenn sie sehr ähnlich aussehen, nutzen sie unter der Haube nicht die gleiche Infrastruktur.
Slugs in der Anwendung nutzen
Versuche, weitere Konferenzen im Backend hinzuzufügen und ändere die Stadt oder das Jahr einer bestehenden Konferenz; der Slug wird nicht aktualisiert, es sei denn Du verwendest den speziellen -
-Wert.
Die letzte Änderung besteht darin, die Controller und die Templates zu anzupassen, sodass diese den Konferenz-slug
anstelle der Konferenz-id
für Routen verwenden:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -28,7 +28,7 @@ class ConferenceController extends AbstractController
]));
}
- #[Route('/conference/{id}', name: 'conference')]
+ #[Route('/conference/{slug}', name: 'conference')]
public function show(Request $request, Conference $conference, CommentRepository $commentRepository): Response
{
$offset = max(0, $request->query->getInt('offset', 0));
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -18,7 +18,7 @@
<h1><a href="{{ path('homepage') }}">Guestbook</a></h1>
<ul>
{% for conference in conferences %}
- <li><a href="{{ path('conference', { id: conference.id }) }}">{{ conference }}</a></li>
+ <li><a href="{{ path('conference', { slug: conference.slug }) }}">{{ conference }}</a></li>
{% endfor %}
</ul>
<hr />
--- a/templates/conference/index.html.twig
+++ b/templates/conference/index.html.twig
@@ -8,7 +8,7 @@
{% for conference in conferences %}
<h4>{{ conference }}</h4>
<p>
- <a href="{{ path('conference', { id: conference.id }) }}">View</a>
+ <a href="{{ path('conference', { slug: conference.slug }) }}">View</a>
</p>
{% endfor %}
{% endblock %}
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -22,10 +22,10 @@
{% endfor %}
{% if previous >= 0 %}
- <a href="{{ path('conference', { id: conference.id, offset: previous }) }}">Previous</a>
+ <a href="{{ path('conference', { slug: conference.slug, offset: previous }) }}">Previous</a>
{% endif %}
{% if next < comments|length %}
- <a href="{{ path('conference', { id: conference.id, offset: next }) }}">Next</a>
+ <a href="{{ path('conference', { slug: conference.slug, offset: next }) }}">Next</a>
{% endif %}
{% else %}
<div>No comments have been posted yet for this conference.</div>
Der Zugriff auf die Konferenzseiten sollte nun über den Slug erfolgen:
Weiterführendes
- Das Doctrine Eventsystem (Lifecycle Callbacks und Listener, Entity Listener und Lifecycle Subscriber);
- Die String-Komponenten-Dokumentation;
- Der Service-Container;
- Das Symfony Services Cheat Sheet.