Doctrine オブジェクトのライフサイクルを管理する
新しくコメントをした際には、自動的に現在の日時が createdAt としてセットされると良いですね。
Doctrine は、データベースに追加されるときや更新されるときといったライフサイクルにおいてオブジェクトやプロパティを操作するいろいろな方法があります。
ライフサイクルのコールバックを定義する
サービスの依存が必要なく、エンティティを1つしか操作しないときは、エンティティクラスにコールバックを定義すると良いでしょう:
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;
    ORM\PrePersist は、最初にデータベースに保存されたときにトリガーとして呼ばれる イベント です。このイベントの際に setCreatedAtValue() メソッドが呼ばれ、現在の日時が createdAt プロパティにセットされます。
カンファンレンスへスラッグを追加する
/conference/1 といったカンファレンスの URL は特に意味はありません。これはデータベースのプライマリーキーといった実装の詳細に依るものになっています。
代わりに /conference/paris-2020 といった URL はどうですか?こちらの方が良いですね。 paris-2020 はカンファレンスの スラッグ と呼んでいます。
カンファレンスに slug プロパティを追加しましょう ( 255文字の長さで nullable でない型です):
1
$ symfony console make:entity Conference
    新しいカラムを追加するのでマイグレーションファイルを作成しましょう:
1
$ symfony console make:migration
    新しいマイグレーションを実行します:
1
$ symfony console doctrine:migrations:migrate
    エラーになりましたが、想定内のことです。先程スラッグは 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
    これで各カンファレンスを探すためにスラッグを使うようにしたので、カンファレンスエンティティを修正して、スラッグがデータベース上でユニークになるようにしましょう:
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()
    既にわかっているとは思いますが、ここでマイグレーションをする必要があります:
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')]
@@ -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;
    computeSlug() メソッドは、現在のスラッグが何も指定していないか - と値が渡ったときのみ動作します。- の値は、バックエンドでカンファレンスを追加するときにスラッグが必須となるので使用します。空ではないこの特別な値でアプリケーションにスラッグを自動生成させることができます。
複雑なライフサイクルのコールバックを定義する
createdAt プロパティのように slug も更新時に computeSlug() メソッドを呼べば自動的にセットされるようにした方が良いですね。
このメソッドは SluggerInterface の実装に依存していますので、以前のように prePersist イベントに追加することはできません。
代わりに 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
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);
    }
}
    新しくカンファレンスが追加されたとき(perPersist())と更新されたとき(preUpdated())に、スラッグは更新されます。
コンテナにサービスを設定する
まだ、Symfony の鍵となるコンポーネント DIコンテナ について話していませんでした。このコンテナは、 サービス を作成したり必要なときにインジェクトしたりといった管理を行います:
サービス は "グローバル" なオブジェクトで、メーラーやロガーやスラッグ作成などの機能を提供します。これらは Doctrine のエンティティのインスタンスのような データオブジェクト とは違います。
実際は、必要なときにサービスが自動的にインジェクトされるのでコンテナを直接使うことはあまりありません。コンテナは型宣言によってコントローラの引数のオブジェクトを注入します。
前のステップでイベントリスナーがどうやって登録されたか不思議に思いませんでしたか?コンテナがその役割を担っていました。クラスが特定のインターフェースを実装すると、コンテナは、そのクラスがどうやって登録されるか知ることになるのです。
ただ、この自動化は、サードパーティのパッケージなどに用意してくれるわけではありません。例えば、さきほど出てきたエンティティのリスナーは、インターフェースを実装していませんし、そういったクラスの継承をしているわけではないので、Symfony のサービスコンテナによって自動的に管理されていません。
コンテナにリスナーを定義する必要があります。依存のワイヤリングは省略することができますが、コンテナが推測できるように手動で タグ を追加して Doctrine のイベントディスパッチャーにリスナーを登録する必要があります。
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
Doctrine のイベントリスナーとSymfony のイベントリスナーは同じように見えますが、内部では異なるインフラストラクチャーを使っており別物ですので注意してください。
アプリケーションでスラッグを使用する
バックエンドでさらにカンファレンスを追加したり、既に登録されているものの年や都市を変更してみましょう。 - を値として使用しなければ、スラッグは更新されません。
最後に行う変更として、コントローラーやテンプレートでカンファレンスの id を指定する代わりに スラッグ を使用するように修正しましょう:
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>
    これでカンファレンスのページへスラッグから辿ることができるようになりました: