Жизненный цикл объектов Doctrine
Было бы неплохо, если при создании нового комментария значение поля createdAt
автоматически заполнялось текущими датой и временем.
Doctrine может по-разному манипулировать объектами и их свойствами в различных стадиях жизненного цикла (до вставки записи в базу данных, после обновления записи и т.д.).
Определение обратных вызовов событий жизненного цикла
Если логика не требует доступа к сервису и применяется только к одному типу сущности, можно определить обратный вызов в классе сущности:
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;
Событие ORM\PrePersist
срабатывает, когда объект впервые сохранятся в базе данных. В этот момент вызывается метод setCreatedAtValue()
, который использует текущие дату и время в качестве значения для свойства createdAt
.
Добавление слагов для конференций
Сейчас URL-адреса конференций вроде /conference/1
не очень понятны. Более того, они раскрывают детали реализации приложения (значение первичного ключа 1
доступно пользователю).
Почему бы не использовать URL-адреса вида /conference/paris-2020
? Они выглядят намного лучше и красивее. Фрагмент адреса paris-2020
— это слаг (человеко-понятная часть URL-адреса) конференции.
Добавьте новое свойство slug
в класс конференции (строка длиной до 255 символов, которая не может быть пустой):
1
$ symfony console make:entity Conference
Создайте файл миграции, чтобы добавить новый столбец:
1
$ symfony console make:migration
А затем выполните новую миграцию:
1
$ symfony console doctrine:migrations:migrate
Увидели ошибку? Это было ожидаемо, потому что мы указали, что свойство slug
не должно быть пустым (содержать значение null
). Но во время миграции существующие в базе данных записи конференций будут перезаписаны значениями null
. Давайте исправим это, поменяв логику процесса миграции:
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
Мы применили некоторую хитрость: сначала добавляем столбец слага с возможностью иметь значение по умолчанию —null
, далее создаём слаг для существующих записей (то есть заполняем новый столбец значениями, отличными от null
), а затем изменяем столбец слага так, чтобы он он не позволял хранить значение null
.
Note
В реальном проекте использование выражения CONCAT(LOWER(city), '-', year)
может быть недостаточным. В таком случае понадобится использовать "настоящий" сервис для генерации слага (слагер).
Теперь миграция должна пройти без ошибок:
1
$ symfony console doctrine:migrations:migrate
Поскольку приложение вскоре будет использовать слаги для поиска каждой конференции, давайте улучшим сущность Conference, чтобы гарантировать уникальность значений слагов в базе данных:
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()
Как вы могли догадаться, нам нужно выполнить процедуру миграции:
1
$ symfony console make:migration
1
$ symfony console doctrine:migrations:migrate
Генерация слагов
Во многих языках создать слаг, который хорошо читается в URL-адресе (где должно быть закодировано все, кроме ASCII-символов), не так-то просто. К примеру, как поменять é
на e
?
Чтобы не изобретать велосипед, давайте воспользуемся Symfony-компонентом String
, который не только облегчает работу со строками, но и содержит слагер.
В класс Conference
добавьте метод computeSlug()
, который исходя из данных конференции сгенерирует слаг:
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;
Метод computeSlug()
генерирует слаг только в том случае, если значение слага отсутствует, либо указанный слаг имеет специальное значение -
. Но зачем оно нужно? Поскольку слаг не может быть пустым, при добавлении конференции в административной панели нам нужно указать некое специальное значение (в нашем случае — -
) в соответствующем поле, чтобы сообщить приложению, что оно должно автоматически сгенерировать слаг.
Определение сложных обратных вызовов жизненного цикла
По аналогии со свойством createdAt
, свойство slug
должно автоматически обновляться при каждом изменении конференции путем вызова метода computeSlug()
.
Но так как метод зависит от реализации SluggerInterface
, мы не можем добавить событие prePersist
так, как делали это раньше (нет способа внедрить слагер).
Вместо этого создайте обработчик сущности Doctrine:
Обратите внимание, что слаг генерируется как при создании новой конференции (prePersist()
), так и при её обновлении (preUpdate()
).
Настройка сервиса в контейнере
До сих пор мы не упомянули один из главных компонентов Symfony — контейнер внедрения зависимостей, который управляет сервисами: создаёт и внедряет их по мере необходимости.
Сервис — это "глобальный" объект с определённой функциональностью (mailer — отправка электронных писем, logger — логирование, slugger — генерация URL-адресов, и т.д.) в отличие от объектов данных (к примеру, экземпляров сущности Doctrine).
Вы редко будете работать с контейнером напрямую, поскольку он автоматически внедряет сервисы, когда это вам необходимо: внедрение объектов-сервисов происходит, когда вы указываете типы соответствующих сервисов в качестве аргументов контроллера.
Теперь вы знаете, что обработчик события в предыдущем примере был зарегистрирован через контейнер. Когда класс реализует определённые интерфейсы, контейнер знает, что класс должен быть зарегистрирован соответствующим образом.
Здесь, поскольку наш класс не реализует ни одного интерфейса и не расширяет ни одного базового класса, Symfony не знает, как его автоматически конфигурировать. Вместо этого мы можем использовать атрибут, чтобы указать контейнеру Symfony, как его подключить:
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
Не путайте обработчики событий Doctrine и обработчики событий Symfony. Даже если они очень похожи, они по-разному работают изнутри.
Использование слагов в приложении
Попробуйте добавить несколько конференций в административной панели, либо измените город или год проведения уже созданных конференций; слаг не обновится, только если вы не укажете в его поле специальное значение — -
.
Осталось сделать последнее изменение — заменить в контроллерах и шаблонах параметр маршрутов конференций с id
на slug
:
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>
Теперь можно перейти к странице конференции через её слаг: