Managing the Lifecycle of Doctrine Objects
When creating a new comment, it would be great if the createdAt
date was 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 was 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 24 25 26 27 28 29 30 31 32 33 34
--- a/src/Controller/Admin/CommentCrudController.php
+++ b/src/Controller/Admin/CommentCrudController.php
@@ -57,8 +57,6 @@ class CommentCrudController extends AbstractCrudController
]);
if (Crud::PAGE_EDIT === $pageName) {
yield $createdAt->setFormTypeOption('disabled', true);
- } else {
- yield $createdAt;
}
}
}
--- a/src/Entity/Comment.php
+++ b/src/Entity/Comment.php
@@ -7,6 +7,7 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: CommentRepository::class)]
+#[ORM\HasLifecycleCallbacks]
class Comment
{
#[ORM\Id]
@@ -86,6 +87,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 not to be null
but existing entries in the conference database will get a null
value when the migration is run. 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]
@@ -30,7 +32,7 @@ class Conference
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'conference', orphanRemoval: true)]
private Collection $comments;
- #[ORM\Column(length: 255)]
+ #[ORM\Column(length: 255, unique: true)]
private ?string $slug = null;
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')]
@@ -50,6 +51,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 like we did (we don't have a way to inject the slugger).
Instead, create a Doctrine entity listener:
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.
Here, because our class doesn't implement any interface or extend any base class, Symfony doesn't know how to auto-configure it. Instead, we can use an attribute to tell the Symfony container how to wire it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
--- a/src/EntityListener/ConferenceEntityListener.php
+++ b/src/EntityListener/ConferenceEntityListener.php
@@ -3,9 +3,13 @@
namespace App\EntityListener;
use App\Entity\Conference;
+use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
+use Doctrine\ORM\Events;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use Symfony\Component\String\Slugger\SluggerInterface;
+#[AsEntityListener(event: Events::prePersist, entity: Conference::class)]
+#[AsEntityListener(event: Events::preUpdate, entity: Conference::class)]
class ConferenceEntityListener
{
public function __construct(
Note
Don't confuse Doctrine event listeners with 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
@@ -20,7 +20,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
@@ -16,7 +16,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: