Step 13: Managing the Lifecycle of Doctrine Objects

5.0 version
Maintained

Managing the Lifecycle of Doctrine Objects

When creating a new comment, it would be great if the createdAt date would be set automatically to the current date and time.

Doctrine has different ways to manipulate objects and their properties during their lifecycle (before the row in the database is created, after the row is updated, …).

Defining Lifecycle Callbacks

When the behavior does not need any service and should be applied to only one kind of entity, define a callback in the 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;

The @ORM\PrePersist event is triggered when the object is stored in the database for the very first time. When that happens, the setCreatedAtValue() method is called and the current date and time is used for the value of the createdAt property.

Adding Slugs to Conferences

The URLs for conferences are not meaningful: /conference/1. More importantly, they depend on an implementation detail (the primary key in the database is leaked).

What about using URLs like /conference/paris-2020 instead? That would look much better. paris-2020 is what we call the conference slug.

Add a new slug property for conferences (a not nullable string of 255 characters):

1
$ symfony console make:entity Conference

Create a migration file to add the new column:

1
$ symfony console make:migration

And execute that new migration:

1
$ symfony console doctrine:migrations:migrate

Got an error? This is expected. Why? Because we asked for the slug to be not null but existing entries in the conference database will get a null value when the migration is ran. Let’s fix that by tweaking the migration:

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

The trick here is to add the column and allow it to be null, then set the slug to a not null value, and finally, change the slug column to not allow null.

Note

For a real project, using CONCAT(LOWER(city), '-', year) might not be enough. In that case, we would need to use the “real” Slugger.

Migration should run fine now:

1
$ symfony console doctrine:migrations:migrate

Because the application will soon use slugs to find each conference, let’s tweak the Conference entity to ensure that slug values are unique in the 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;

As you might have guessed, we need to perform the migration dance:

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

Generating Slugs

Generating a slug that reads well in a URL (where anything besides ASCII characters should be encoded) is a challenging task, especially for languages other than English. How do you convert é to e for instance?

Instead of reinventing the wheel, let’s use the Symfony String component, which eases the manipulation of strings and provides a slugger:

1
$ symfony composer req string

Add a computeSlug() method to the Conference class that computes the slug based on the conference data:

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;

The computeSlug() method only computes a slug when the current slug is empty or set to the special - value. Why do we need the - special value? Because when adding a conference in the backend, the slug is required. So, we need a non-empty value that tells the application that we want the slug to be automatically generated.

Defining a Complex Lifecycle Callback

As for the createdAt property, the slug one should be set automatically whenever the conference is updated by calling the computeSlug() method.

But as this method depends on a SluggerInterface implementation, we cannot add a prePersist event as before (we don’t have a way to inject the slugger).

Instead, create a 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);
    }
}

Note that the slug is updated when a new conference is created (prePersist()) and whenever it is updated (preUpdate()).

Configuring a Service in the Container

Up until now, we have not talked about one key component of Symfony, the dependency injection container. The container is responsible for managing services: creating them and injecting them whenever needed.

A service is a “global” object that provides features (e.g. a mailer, a logger, a slugger, etc.) unlike data objects (e.g. Doctrine entity instances).

You rarely interact with the container directly as it automatically injects service objects whenever you need them: the container injects the controller argument objects when you type-hint them for instance.

If you wondered how the event listener was registered in the previous step, you now have the answer: the container. When a class implements some specific interfaces, the container knows that the class needs to be registered in a certain way.

Unfortunately, automation is not provided for everything, especially for third-party packages. The entity listener that we just wrote is one such example; it cannot be managed by the Symfony service container automatically as it does not implement any interface and it does not extend a “well-know class”.

We need to partially declare the listener in the container. The dependency wiring can be omitted as it can still be guessed by the container, but we need to manually add some tags to register the listener with the 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'}

Note

Don’t confuse Doctrine event listeners and Symfony ones. Even if they look very similar, they are not using the same infrastructure under the hood.

Using Slugs in the Application

Try adding more conferences in the backend and change the city or the year of an existing one; the slug won’t be updated except if you use the special - value.

The last change is to update the controllers and the templates to use the conference slug instead of the conference id for routes:

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

Accessing conference pages should now be done via its slug:


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