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:
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;
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:
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
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.
Note
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:
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()
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.
Voeg een computeSlug()
methode toe aan de Conference
-class die de slug baseert op de gegevens van de conferentie:
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;
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:
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:
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
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:
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>
De conferentiepagina's moeten nu aangeroepen worden via de slug: