Schritt 13: Den Lifecycle von Doctrine-Objekten verwalten

5.0 version
Maintained

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:

patch_file
 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
--- a/src/Entity/Comment.php
+++ b/src/Entity/Comment.php
@@ -7,6 +7,7 @@ use Doctrine\ORM\Mapping as ORM;

 /**
  * @ORM\Entity(repositoryClass=CommentRepository::class)
+ * @ORM\HasLifecycleCallbacks()
  */
 class Comment
 {
@@ -106,6 +107,14 @@ class Comment
         return $this;
     }

+    /**
+     * @ORM\PrePersist
+     */
+    public function setCreatedAtValue()
+    {
+        $this->createdAt = new \DateTime();
+    }
+
     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:

patch_file
 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 Version20200714152808 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.

Bemerkung

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:

patch_file
 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
@@ -5,9 +5,11 @@ namespace App\Entity;
 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
 {
@@ -39,7 +41,7 @@ class Conference
     private $comments;

     /**
-     * @ORM\Column(type="string", length=255)
+     * @ORM\Column(type="string", length=255, unique=true)
      */
     private $slug;

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:

1
$ symfony composer req string

Füge in der Conference-Klasse eine computeSlug()-Methode hinzu, die den Slug basierend auf den Konferenzdaten erstellt:

patch_file
 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
@@ -6,6 +6,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)
@@ -60,6 +61,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:

src/EntityListener/ConferenceEntityListener.php
 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
namespace App\EntityListener;

use App\Entity\Conference;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Symfony\Component\String\Slugger\SluggerInterface;

class ConferenceEntityListener
{
    private $slugger;

    public function __construct(SluggerInterface $slugger)
    {
        $this->slugger = $slugger;
    }

    public function prePersist(Conference $conference, LifecycleEventArgs $event)
    {
        $conference->computeSlug($this->slugger);
    }

    public function preUpdate(Conference $conference, LifecycleEventArgs $event)
    {
        $conference->computeSlug($this->slugger);
    }
}

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:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -25,3 +25,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'}

Bemerkung

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:

patch_file
 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
@@ -31,7 +31,7 @@ class ConferenceController extends AbstractController
     }

     /**
-     * @Route("/conference/{id}", name="conference")
+     * @Route("/conference/{slug}", name="conference")
      */
     public function show(Request $request, Conference $conference, CommentRepository $commentRepository)
     {
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -10,7 +10,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/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>
--- 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 %}

Der Zugriff auf die Konferenzseiten sollte nun über den Slug erfolgen:


  • « Previous Schritt 12: Mit Events arbeiten
  • Next » Schritt 14: Feedback mit Formularen annehmen

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.