Skip to content

ユーザーインターフェースを構築する

Webサイトのインターフェースについて、最初のバージョンを作成する準備が整いました。ここではまず動作することを優先し、見た目は重視しません。

セキュリティのため、前の章でコントローラーでエスケーピング処理を施したことを思い出してください。このため、私たちはテンプレートエンジンとしてPHPを使うことはありません。代わりに、Twigを使います。出力のエスケーピング処理の他に、 Twig は、例えばテンプレートの継承といった、多くの便利な機能を備えています。

Twigをテンプレートとして使う

Webサイト上のすべてのページは同じ レイアウト を共有します。Twigをインストールすると、 templates/ ディレクトリが自動的に作成され、サンプルレイアウトとして、base.html.twig も同様に作成されます。

templates/base.html.twig
1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Welcome!{% endblock %}</title>
        {% block stylesheets %}{% endblock %}
    </head>
    <body>
        {% block body %}{% endblock %}
        {% block javascripts %}{% endblock %}
    </body>
</html>

レイアウトには block 要素を定義できます。 block 要素には、レイアウトを 拡張 した各 子テンプレート が個々の内容でコンテンツを追加できます。

プロジェクトのホームページ用に、 templates/conference/index.html.twig としてテンプレートを作ってみましょう:

templates/conference/index.html.twig
1
2
3
4
5
6
7
8
9
10
11
{% extends 'base.html.twig' %}

{% block title %}Conference Guestbook{% endblock %}

{% block body %}
    <h2>Give your feedback!</h2>

    {% for conference in conferences %}
        <h4>{{ conference }}</h4>
    {% endfor %}
{% endblock %}

base.html.twig拡張 したこのテンプレートは、 title ブロックと body ブロックの再定義を行っています。

テンプレートの記法 {% %}アクション構造 を表します。

記法 {{ }} は、何かを 表示 する際に使われます。 {{ conference }} はカンファレンスの表現( Conference オブジェクトで __toString を呼び出した結果)を表示します。

コントローラーで Twig を使う

コントローラーを変更して Twig テンプレートをレンダリングしてください:

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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -2,22 +2,19 @@

 namespace App\Controller;

+use App\Repository\ConferenceRepository;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Annotation\Route;
+use Twig\Environment;

 class ConferenceController extends AbstractController
 {
     #[Route('/', name: 'homepage')]
-    public function index(): Response
+    public function index(Environment $twig, ConferenceRepository $conferenceRepository): Response
     {
-        return new Response(<<<EOF
-<html>
-    <body>
-        <img src="/images/under-construction.gif" />
-    </body>
-</html>
-EOF
-        );
+        return new Response($twig->render('conference/index.html.twig', [
+            'conferences' => $conferenceRepository->findAll(),
+        ]));
     }
 }

ここでは多くのことが行われています。

テンプレートをレンダリングするには、Twig Environment オブジェクト (Twig のメインエントリーポイント) が必要です。コントローラーのメソッドに型宣言を付ければ Twig のインスタンスと連携することができます。それだけで Symfony は適切なオブジェクトを注入できるのです。

カンファレンスのリポジトリを使ってデータベースからすべてのカンファレンスを取得してくる必要もあります。

コントローラーのコードでは、 render() メソッドがテンプレートをレンダリングし、変数の配列をテンプレートに渡します。 Conference オブジェクトのリストを conferences 変数として渡します。

コントローラーは普通のPHPクラスです。依存していないことを明示したければ AbstractController クラスを継承する必要さえありません。AbstractController クラスは削除することができます(ただし、その便利な機能をこの後のステップで使う予定ですから、残しておいてください)。

カンファレンスページを作成する

各カンファレンスには、コメントを一覧表示する専用のページが必要です。新しいページを作成するには、コントローラーを追加してルート定義を行い、関連付けたテンプレートを置きます。

src/Controller/ConferenceController.phpshow() メソッドを追加してください:

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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -2,6 +2,8 @@

 namespace App\Controller;

+use App\Entity\Conference;
+use App\Repository\CommentRepository;
 use App\Repository\ConferenceRepository;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\Response;
@@ -17,4 +19,13 @@ class ConferenceController extends AbstractController
             'conferences' => $conferenceRepository->findAll(),
         ]));
     }
+
+    #[Route('/conference/{id}', name: 'conference')]
+    public function show(Environment $twig, Conference $conference, CommentRepository $commentRepository): Response
+    {
+        return new Response($twig->render('conference/show.html.twig', [
+            'conference' => $conference,
+            'comments' => $commentRepository->findBy(['conference' => $conference], ['createdAt' => 'DESC']),
+        ]));
+    }
 }

このメソッドには、これまでにはなかった特別な振る舞いがあります。メソッドに Conference インスタンスの注入が必要です。データベースに複数あるかもしれません。Symfony ではリクエストのパスで受け取る {id} によってどのレコードを取得するのかを決めることができます( id はデータベースの conference テーブルのプライマリーキーです)。

カンファレンスに関連付けられたコメントを取得するには、検索条件を第一引数に取る findBy() メソッドを使います。

最後のステップとしてファイル templates/conference/show.html.twig を作成します:

templates/conference/show.html.twig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{% extends 'base.html.twig' %}

{% block title %}Conference Guestbook - {{ conference }}{% endblock %}

{% block body %}
    <h2>{{ conference }} Conference</h2>

    {% if comments|length > 0 %}
        {% for comment in comments %}
            {% if comment.photofilename %}
                <img src="{{ asset('uploads/photos/' ~ comment.photofilename) }}" />
            {% endif %}

            <h4>{{ comment.author }}</h4>
            <small>
                {{ comment.createdAt|format_datetime('medium', 'short') }}
            </small>

            <p>{{ comment.text }}</p>
        {% endfor %}
    {% else %}
        <div>No comments have been posted yet for this conference.</div>
    {% endif %}
{% endblock %}

このテンプレートでは、Twig フィルター を呼び出すために | 記法を使っています。フィルターは値の変換をします。 comments|length はコメントの数を返し、 comment.createdAt|format_datetime('medium', 'short') はヒューマンリーダブルな表現で日付をフォーマットします。

/conference/ 1 で "最初の" カンファレンスにアクセスして次のエラーを確認してください:

/conference/1

このエラーは、 format_datetime フィルターが Twig コアには同梱されていないために発生するものです。エラーメッセージは、問題を解決するにはどのパッケージをインストールする必要があるのかについてヒントを提供してくれます:

1
$ symfony composer req "twig/intl-extra:^3"

これで、ページが正しく動作するようになりました。

ページをリンクする

ユーザーインターフェースの初期バージョンを作る最後のステップとして、ホームページからカンファレンスページにリンクをつけます:

1
2
3
4
5
6
7
8
9
10
11
--- a/templates/conference/index.html.twig
+++ b/templates/conference/index.html.twig
@@ -7,5 +7,8 @@

     {% for conference in conferences %}
         <h4>{{ conference }}</h4>
+        <p>
+            <a href="/conference/{{ conference.id }}">View</a>
+        </p>
     {% endfor %}
 {% endblock %}

しかし、パスをハードコードしてしまうのは複数の理由から悪手と言えます。もっとも大きな理由として、パスを変更する場合 (たとえば、 /conference/{id} から /conferences/{id} にする場合) 、すべてのリンクを手動で更新しなければならないことが挙げられます。

ハードコードを使わない代わりに、Twig 関数 path()ルート名 を使ってください:

1
2
3
4
5
6
7
8
9
10
11
--- 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="/conference/{{ conference.id }}">View</a>
+            <a href="{{ path('conference', { id: conference.id }) }}">View</a>
         </p>
     {% endfor %}
 {% endblock %}

path() 関数は、ルート名からページパスを生成します。ルートパラメーターの値は Twig マップとして渡されます。

コメントのページネーション

何千人も参加者がいるので、かなりの数のコメントがつくはずです。すべてのコメントを1つのページに表示してしまうと、ものすごい勢いで伸びていってしまいます。

コメントリポジトリで getCommentPaginator() メソッドを作成します。このメソッドはカンファレンスとオフセット(開始点)からコメントの Paginator を返します:

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
--- a/src/Repository/CommentRepository.php
+++ b/src/Repository/CommentRepository.php
@@ -3,8 +3,10 @@
 namespace App\Repository;

 use App\Entity\Comment;
+use App\Entity\Conference;
 use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
 use Doctrine\Persistence\ManagerRegistry;
+use Doctrine\ORM\Tools\Pagination\Paginator;

 /**
  * @method Comment|null find($id, $lockMode = null, $lockVersion = null)
@@ -14,11 +16,27 @@ use Doctrine\Persistence\ManagerRegistry;
  */
 class CommentRepository extends ServiceEntityRepository
 {
+    public const PAGINATOR_PER_PAGE = 2;
+
     public function __construct(ManagerRegistry $registry)
     {
         parent::__construct($registry, Comment::class);
     }

+    public function getCommentPaginator(Conference $conference, int $offset): Paginator
+    {
+        $query = $this->createQueryBuilder('c')
+            ->andWhere('c.conference = :conference')
+            ->setParameter('conference', $conference)
+            ->orderBy('c.createdAt', 'DESC')
+            ->setMaxResults(self::PAGINATOR_PER_PAGE)
+            ->setFirstResult($offset)
+            ->getQuery()
+        ;
+
+        return new Paginator($query);
+    }
+
     // /**
     //  * @return Comment[] Returns an array of Comment objects
     //  */

テストを簡単にするため、ページあたりのコメント最大数を2に設定しました。

テンプレートのページネーションを管理するには、Doctrine コレクションの代わりに Doctrine Paginator を Twig に渡します:

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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -6,6 +6,7 @@ use App\Entity\Conference;
 use App\Repository\CommentRepository;
 use App\Repository\ConferenceRepository;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Annotation\Route;
 use Twig\Environment;
@@ -21,11 +22,16 @@ class ConferenceController extends AbstractController
     }

     #[Route('/conference/{id}', name: 'conference')]
-    public function show(Environment $twig, Conference $conference, CommentRepository $commentRepository): Response
+    public function show(Request $request, Environment $twig, Conference $conference, CommentRepository $commentRepository): Response
     {
+        $offset = max(0, $request->query->getInt('offset', 0));
+        $paginator = $commentRepository->getCommentPaginator($conference, $offset);
+
         return new Response($twig->render('conference/show.html.twig', [
             'conference' => $conference,
-            'comments' => $commentRepository->findBy(['conference' => $conference], ['createdAt' => 'DESC']),
+            'comments' => $paginator,
+            'previous' => $offset - CommentRepository::PAGINATOR_PER_PAGE,
+            'next' => min(count($paginator), $offset + CommentRepository::PAGINATOR_PER_PAGE),
         ]));
     }
 }

コントローラーはリクエストのクエリー文字列 ($request->query) から offset を整数として (getInt()) 取得します。デフォルトは0です。

previousnext のオフセットは Doctrine Paginator から得られる全情報から算出されます。

最後に、テンプレートを更新して次ページ、前ページへのリンクを追加しましょう:

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
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -6,6 +6,8 @@
     <h2>{{ conference }} Conference</h2>

     {% if comments|length > 0 %}
+        <div>There are {{ comments|length }} comments.</div>
+
         {% for comment in comments %}
             {% if comment.photofilename %}
                 <img src="{{ asset('uploads/photos/' ~ comment.photofilename) }}" />
@@ -18,6 +20,13 @@

             <p>{{ comment.text }}</p>
         {% endfor %}
+
+        {% if previous >= 0 %}
+            <a href="{{ path('conference', { id: conference.id, offset: previous }) }}">Previous</a>
+        {% endif %}
+        {% if next < comments|length %}
+            <a href="{{ path('conference', { id: conference.id, offset: next }) }}">Next</a>
+        {% endif %}
     {% else %}
         <div>No comments have been posted yet for this conference.</div>
     {% endif %}

これで、"前" と "次" のリンクからコメントのナビゲーションができるようになりました:

/conference/1
/conference/1?offset=2

コントローラーをリファクタリングする

ConferenceController の両方のメソッドが Twig 環境を引数として取っていることに気がつくでしょうか。各メソッドでインジェクトするのではなく、コンストラクターインジェクションを使うようにしましょう(引数のリストが短くなり冗長性がなくなります):

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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -13,21 +13,28 @@ use Twig\Environment;

 class ConferenceController extends AbstractController
 {
+    private $twig;
+
+    public function __construct(Environment $twig)
+    {
+        $this->twig = $twig;
+    }
+
     #[Route('/', name: 'homepage')]
-    public function index(Environment $twig, ConferenceRepository $conferenceRepository): Response
+    public function index(ConferenceRepository $conferenceRepository): Response
     {
-        return new Response($twig->render('conference/index.html.twig', [
+        return new Response($this->twig->render('conference/index.html.twig', [
             'conferences' => $conferenceRepository->findAll(),
         ]));
     }

     #[Route('/conference/{id}', name: 'conference')]
-    public function show(Request $request, Environment $twig, Conference $conference, CommentRepository $commentRepository): Response
+    public function show(Request $request, Conference $conference, CommentRepository $commentRepository): Response
     {
         $offset = max(0, $request->query->getInt('offset', 0));
         $paginator = $commentRepository->getCommentPaginator($conference, $offset);

-        return new Response($twig->render('conference/show.html.twig', [
+        return new Response($this->twig->render('conference/show.html.twig', [
             'conference' => $conference,
             'comments' => $paginator,
             'previous' => $offset - CommentRepository::PAGINATOR_PER_PAGE,
This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.
TOC
    Version