Skip to content
  • About
    • What is Symfony?
    • Community
    • News
    • Contributing
    • Support
  • Documentation
    • Symfony Docs
    • Symfony Book
    • Screencasts
    • Symfony Bundles
    • Symfony Cloud
    • Training
  • Services
    • Platform.sh for Symfony Best platform to deploy Symfony apps
    • SymfonyInsight Automatic quality checks for your apps
    • Symfony Certification Prove your knowledge and boost your career
    • SensioLabs Professional services to help you with Symfony
    • Blackfire Profile and monitor performance of your apps
  • Other
  • Blog
  • Download
sponsored by
  1. Home
  2. Documentation
  3. Symfony: The Fast Track
  4. English
  5. Managing the Lifecycle of Doctrine Objects

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:

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;

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:

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

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:

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()

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.

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

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;

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:

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

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:

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>

Accessing conference pages should now be done via its slug:

/conference/amsterdam-2019

Going Further

  • The Doctrine event system (lifecycle callbacks and listeners, entity listeners and lifecycle subscribers);
  • The String component docs;
  • The Service container;
  • The Symfony Services Cheat Sheet.
Previous page Listening to Events
Next page Accepting Feedback with Forms
This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.
TOC
    Version

    Symfony 5.4 is backed by

    Online Symfony certification, take it now!

    Online Symfony certification, take it now!

    No stress: we've got you covered with our 116 automated quality checks of your code

    No stress: we've got you covered with our 116 automated quality checks of your code

    Version:
    Locale:
    ebook

    This book is backed by:

    see all backers

    Symfony footer

    Avatar of zenmate, a Symfony contributor

    Thanks zenmate for being a Symfony contributor

    2 commits • 186 lines changed

    View all contributors that help us make Symfony

    Become a Symfony contributor

    Be an active part of the community and contribute ideas, code and bug fixes. Both experts and newcomers are welcome.

    Learn how to contribute

    Symfony™ is a trademark of Symfony SAS. All rights reserved.

    • What is Symfony?

      • What is Symfony?
      • Symfony at a Glance
      • Symfony Components
      • Symfony Releases
      • Security Policy
      • Logo & Screenshots
      • Trademark & Licenses
      • symfony1 Legacy
    • Learn Symfony

      • Symfony Docs
      • Symfony Book
      • Reference
      • Bundles
      • Best Practices
      • Training
      • eLearning Platform
      • Certification
    • Screencasts

      • Learn Symfony
      • Learn PHP
      • Learn JavaScript
      • Learn Drupal
      • Learn RESTful APIs
    • Community

      • Symfony Community
      • SymfonyConnect
      • Events & Meetups
      • Projects using Symfony
      • Contributors
      • Symfony Jobs
      • Backers
      • Code of Conduct
      • Downloads Stats
      • Support
    • Blog

      • All Blog Posts
      • A Week of Symfony
      • Case Studies
      • Cloud
      • Community
      • Conferences
      • Diversity
      • Living on the edge
      • Releases
      • Security Advisories
      • Symfony Insight
      • Twig
      • SensioLabs Blog
    • Services

      • SensioLabs services
      • Train developers
      • Manage your project quality
      • Improve your project performance
      • Host Symfony projects

      Powered by

    Follow Symfony