Stap 13: De lifecycle van Doctrine-objecten beheren

5.0 version
Maintained

De lifecycle van Doctrine-objecten beheren

Bij het maken van een nieuwe reactie zou het geweldig zijn als de createdAt-datum automatisch op de huidige datum en tijd zou worden ingesteld.

Doctrine heeft verschillende manieren om objecten en hun properties te manipuleren tijdens hun lifecycle (voordat de rij in de database wordt aangemaakt, nadat de rij is bijgewerkt, ….).

Definiëren van lifecycle callbacks

Wanneer het gedrag geen service nodig heeft en slechts op één soort entity moet worden toegepast, definieer dan een callback in de entity class:

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;

Het @ORM\PrePersist-event wordt geactiveerd wanneer het object voor het eerst in de database wordt opgeslagen. Als dat gebeurt, wordt de setCreatedAtValue()-methode aangeroepen en wordt de huidige datum en tijd gebruikt voor de waarde van het createdAt-property.

Slugs toevoegen aan conferenties

De URL’s voor conferenties hebben momenteel geen betekenis: /conference/1. Belangrijker nog, ze zijn afhankelijk van een implementatiedetail (de primaire sleutel in de database is openbaar).

Misschien kunnen we in plaats daarvan beter gebruik maken van URL’s zoals /conference/paris-2020? Dat zou er veel beter uitzien. paris-2020 is wat we noemen de slug van de conferentie.

Voeg een nieuw slug-property toe voor conferenties (een niet nullable string van 255 tekens):

1
$ symfony console make:entity Conference

Maak een migratiebestand aan om de nieuwe kolom toe te voegen:

1
$ symfony console make:migration

En voer die nieuwe migratie uit:

1
$ symfony console doctrine:migrations:migrate

Krijg je een foutmelding? Dat is zoals verwacht. Waarom? Omdat we gevraagd hebben om de slug niet null te laten zijn, maar bestaande gegevens in de conferentiedatabase zullen een waarde van null krijgen wanneer de migratie wordt uitgevoerd. Laten we dat oplossen door de migratie aan te passen:

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

De truc hier is om de kolom toe te voegen en toe te laten dat deze null mag zijn. Vervolgens geef je de slug een waarde, om daarna weer toe te staan dat de kolom niet null mag zijn.

Notitie

Voor een echt project is het gebruik van CONCAT(LOWER(city), '-', year) misschien niet genoeg. In dat geval zouden we de “echte” Slugger moeten gebruiken.

De migratie zou nu goed moeten verlopen:

1
$ symfony console doctrine:migrations:migrate

Omdat de applicatie binnenkort gebruik zal maken van slugs om elke conferentie te vinden, moeten we de conferentie-entiteit aanpassen om ervoor te zorgen dat de slugs uniek zijn in de database:

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;

Zoals je misschien al had geraden, moeten we de migratie-truc uitvoeren:

1
$ symfony console make:migration
1
$ symfony console doctrine:migrations:migrate

Slugs genereren

Het genereren van een slug die goed leesbaar is in een URL (waar alles behalve ASCII-tekens encoded moet worden), is een uitdagende taak. Vooral voor andere talen dan het Engels. Hoe converteer je é naar e bijvoorbeeld?

In plaats van het wiel opnieuw uit te vinden, gebruiken we de Symfony String component, die de manipulatie van strings makkelijker maakt en een slugger bevat:

1
$ symfony composer req string

Voeg een computeSlug() methode toe aan de Conference-class die de slug baseert op de gegevens van de conferentie:

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;

De computeSlug()-methode bouwt alleen een slug op wanneer de huidige slug leeg is of gelijk is aan de speciale waarde -. Waarom hebben we de speciale waarde - nodig? Omdat bij het toevoegen van een conferentie in de backend, de slug noodzakelijk is. We hebben dus een niet-lege waarde nodig die de applicatie vertelt dat we willen dat de slug automatisch gegenereerd wordt.

Een complexe lifecycle callback definiëren

Net als de createdAt-property, moet de slug automatisch gegenereerd worden wanneer de conferentie wordt bijgewerkt, door middel van het aanroepen van de computeSlug methode.

Maar omdat deze methode afhankelijk is van een implementatie van SluggerInterface, kunnen we geen prePersist-event toevoegen zoals voorheen (we hebben geen manier om de slugger te injecteren).

Maak in plaats daarvan een 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);
    }
}

Merk op dat de slug wordt bijgewerkt wanneer er een nieuwe conferentie wordt aangemaakt ( prePersist() ) en wanneer deze wordt bijgewerkt ( preUpdate() ).

Een service in de container configureren

Tot nu toe hebben we het niet gehad over één belangrijk onderdeel van Symfony, de dependency injection container. De container is verantwoordelijk voor het beheer van de services: het creëren en injecteren van de services wanneer dat nodig is.

Een service is een “global” object dat functies biedt (bv. een mailer, een logger, een slugger, etc.) in tegenstelling tot data-objecten (bv. instanties van Doctrine-entity’s).

Je hebt zelden direct interactie met de container, omdat deze automatisch service-objecten injecteert wanneer je ze nodig hebt: de container injecteert de objecten als argumenten van de controller wanneer je ze type-hint bijvoorbeeld.

Als je je afvroeg hoe de event listener in de vorige stap werd geregistreerd, dan heb je nu het antwoord: de container. Wanneer een class een aantal specifieke interfaces implementeert, dan weet de container dat de class op een bepaalde manier geregistreerd moet worden.

Helaas is niet alles geautomatiseerd, vooral niet voor packages van derden. De entity listener die we net schreven is zo’n voorbeeld; deze kan niet automatisch worden beheerd door de Symfony-servicecontainer, omdat het geen enkele interface implementeert en het breidt geen “well-known class” uit.

We moeten de listener in de container gedeeltelijk declareren. Het expliciet toevoegen van de dependencies kan weggelaten worden, omdat dit nog steeds geraden kan worden door de container, maar we moeten wel handmatig enkele tags toevoegen om de listener te registreren bij de Doctrine event dispatcher:

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'}

Notitie

Verwar Doctrine event listeners niet met Symfony listeners. Ook al lijken ze erg op elkaar, toch gebruiken ze niet dezelfde infrastructuur onder de motorkap.

Het gebruik van slugs in de applicatie

Probeer meer conferenties toe te voegen in de backend en verander de stad of het jaar van een bestaande conferentie; de slug zal niet worden bijgewerkt, behalve als je de speciale --waarde gebruikt.

De laatste wijziging is het bijwerken van de controllers en de templates om de slug van de conferentie te gebruiken voor routes, in plaats van het id van de conferentie:

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 %}

De conferentiepagina’s moeten nu aangeroepen worden via de slug:


  • « Previous Stap 12: Luisteren naar events
  • Next » Stap 14: Feedback ontvangen via formulieren

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