Skip to content

フォームでフィードバックを受ける

カンファレンスの参加者からフィードバックをしてもらうようにしましょう。 HTML フォーム からコメントを投稿できるようにしましょう。

フォームタイプを生成する

Maker バンドルを使ってフォームクラスを生成します:

1
$ symfony console make:form CommentFormType Comment
1
2
3
4
5
6
7
8
created: src/Form/CommentFormType.php


 Success!


Next: Add fields to your form and start using it.
Find the documentation at https://symfony.com/doc/current/forms.html

App\Form\CommentFormType クラスは `AppEntityComment`` エンティティのフォームを定義します:

src/App/Form/CommentFormType.php
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
namespace App\Form;

use App\Entity\Comment;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class CommentFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('author')
            ->add('text')
            ->add('email')
            ->add('createdAt')
            ->add('photoFilename')
            ->add('conference')
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Comment::class,
        ]);
    }
}

フォームタイプ は、モデルに紐付けられた フォームフィールド です。投稿されたデータとモデルクラスのプロパティの変換を行います。デフォルトでは、Symfonyは Comment エンティティのメタデータ(Doctrine のメタデータ)を使用して各フィールドの設定を推測します。例えば、 text フィールドはデータベースで大きなカラムとして定義されているので textarea が使われます。

フォームを表示する

ユーザーにフォームを表示するには、コントローラーでフォームを作成し、それをテンプレートに渡しましょう:

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

 namespace App\Controller;

+use App\Entity\Comment;
 use App\Entity\Conference;
+use App\Form\CommentFormType;
 use App\Repository\CommentRepository;
 use App\Repository\ConferenceRepository;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -31,6 +33,9 @@ class ConferenceController extends AbstractController
     #[Route('/conference/{slug}', name: 'conference')]
     public function show(Request $request, Conference $conference, CommentRepository $commentRepository): Response
     {
+        $comment = new Comment();
+        $form = $this->createForm(CommentFormType::class, $comment);
+
         $offset = max(0, $request->query->getInt('offset', 0));
         $paginator = $commentRepository->getCommentPaginator($conference, $offset);

@@ -39,6 +44,7 @@ class ConferenceController extends AbstractController
             'comments' => $paginator,
             'previous' => $offset - CommentRepository::PAGINATOR_PER_PAGE,
             'next' => min(count($paginator), $offset + CommentRepository::PAGINATOR_PER_PAGE),
+            'comment_form' => $form->createView(),
         ]));
     }
 }

フォームタイプを直接生成してはいけません。代わりに、createForm() メソッドを使用してください。このメソッドは AbstractController で実装されており、フォーム作成を簡単にしています。

フォームをテンプレートに渡すには、createView() メソッドを使い、テンプレートのためのフォーマットに変換します。

テンプレートにフォームを表示するには、Twig の関数の form を使います:

1
2
3
4
5
6
7
8
9
10
11
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -30,4 +30,8 @@
     {% else %}
         <div>No comments have been posted yet for this conference.</div>
     {% endif %}
+
+    <h2>Add your own feedback</h2>
+
+    {{ form(comment_form) }}
 {% endblock %}

ブラウザのカンファレンスページを再読み込みすると、フォームの各フィールドが HTML に表示されているはずです(データタイプはモデルから派生しています):

/conference/amsterdam-2019

form() 関数はフォームタイプで定義された全ての情報を元に HTML フォームを生成します。ファイルアップロードのフィールドがある際には、<form> タグに enctype=multipart/form-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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
--- a/src/Form/CommentFormType.php
+++ b/src/Form/CommentFormType.php
@@ -4,20 +4,31 @@ namespace App\Form;

 use App\Entity\Comment;
 use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\EmailType;
+use Symfony\Component\Form\Extension\Core\Type\FileType;
+use Symfony\Component\Form\Extension\Core\Type\SubmitType;
 use Symfony\Component\Form\FormBuilderInterface;
 use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\Component\Validator\Constraints\Image;

 class CommentFormType extends AbstractType
 {
     public function buildForm(FormBuilderInterface $builder, array $options): void
     {
         $builder
-            ->add('author')
+            ->add('author', null, [
+                'label' => 'Your name',
+            ])
             ->add('text')
-            ->add('email')
-            ->add('createdAt')
-            ->add('photoFilename')
-            ->add('conference')
+            ->add('email', EmailType::class)
+            ->add('photo', FileType::class, [
+                'required' => false,
+                'mapped' => false,
+                'constraints' => [
+                    new Image(['maxSize' => '1024k'])
+                ],
+            ])
+            ->add('submit', SubmitType::class)
         ;
     }

サブミットボタンを1つ追加しました。(テンプレートで {{ form(comment_form) }} 式を指定しただけのままで可能です)。

photoFilename など、自動設定ができないフィールドもあります。 Comment エンティティは、写真のファイル名のみを保存する必要がありますが、フォームはファイルアップロードを処理する必要があります。このケースでは、mapped フィールドを false として photo を追加します。このことで、 Comment のどのプロパティにもマップされないようになります。アップロードした写真をディスクに保存するといった処理は手動で実装をします。

カスタマイズの例として、フィールドのデフォルトのラベルも修正しました。

/conference/amsterdam-2019

モデルをバリデートする

フォームタイプは、フォームのWeb上で表示内容を設定します(HTML5バリデーションなど)。以下が生成された HTML フォームです:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<form name="comment_form" method="post" enctype="multipart/form-data">
    <div id="comment_form">
        <div >
            <label for="comment_form_author" class="required">Your name</label>
            <input type="text" id="comment_form_author" name="comment_form[author]" required="required" maxlength="255" />
        </div>
        <div >
            <label for="comment_form_text" class="required">Text</label>
            <textarea id="comment_form_text" name="comment_form[text]" required="required"></textarea>
        </div>
        <div >
            <label for="comment_form_email" class="required">Email</label>
            <input type="email" id="comment_form_email" name="comment_form[email]" required="required" />
        </div>
        <div >
            <label for="comment_form_photo">Photo</label>
            <input type="file" id="comment_form_photo" name="comment_form[photo]" />
        </div>
        <div >
            <button type="submit" id="comment_form_submit" name="comment_form[submit]">Submit</button>
        </div>
        <input type="hidden" id="comment_form__token" name="comment_form[_token]" value="DwqsEanxc48jofxsqbGBVLQBqlVJ_Tg4u9-BL1Hjgac" />
    </div>
</form>

フォームは、コメントをした人のメールアドレスでは、 email 入力を使用し、ほとんどのフィールドを required とします。また、hidden フィールドの _token フィールドで CSRF attacks 対策をしています。

cURL などの HTTP クライアントを使用するなどして HTML バリデーション が効かないときは、サーバまで無効なデータが到達してしまいます。

Comment データモデルにもバリデーション制約を追加する必要があります:

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
--- a/src/Entity/Comment.php
+++ b/src/Entity/Comment.php
@@ -4,6 +4,7 @@ namespace App\Entity;

 use App\Repository\CommentRepository;
 use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Validator\Constraints as Assert;

 #[ORM\Entity(repositoryClass: CommentRepository::class)]
 #[ORM\HasLifecycleCallbacks]
@@ -15,12 +16,16 @@ class Comment
     private $id;

     #[ORM\Column(type: 'string', length: 255)]
+    #[Assert\NotBlank]
     private $author;

     #[ORM\Column(type: 'text')]
+    #[Assert\NotBlank]
     private $text;

     #[ORM\Column(type: 'string', length: 255)]
+    #[Assert\NotBlank]
+    #[Assert\Email]
     private $email;

     #[ORM\Column(type: 'datetime_immutable')]

フォームを処理する

これでフォームを表示する準備ができました。

フォームを送信してコントローラーでデータベースに情報を永続化する処理をします:

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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -7,6 +7,7 @@ use App\Entity\Conference;
 use App\Form\CommentFormType;
 use App\Repository\CommentRepository;
 use App\Repository\ConferenceRepository;
+use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
@@ -16,10 +17,12 @@ use Twig\Environment;
 class ConferenceController extends AbstractController
 {
     private $twig;
+    private $entityManager;

-    public function __construct(Environment $twig)
+    public function __construct(Environment $twig, EntityManagerInterface $entityManager)
     {
         $this->twig = $twig;
+        $this->entityManager = $entityManager;
     }

     #[Route('/', name: 'homepage')]
@@ -35,6 +38,15 @@ class ConferenceController extends AbstractController
     {
         $comment = new Comment();
         $form = $this->createForm(CommentFormType::class, $comment);
+        $form->handleRequest($request);
+        if ($form->isSubmitted() && $form->isValid()) {
+            $comment->setConference($conference);
+
+            $this->entityManager->persist($comment);
+            $this->entityManager->flush();
+
+            return $this->redirectToRoute('conference', ['slug' => $conference->getSlug()]);
+        }

         $offset = max(0, $request->query->getInt('offset', 0));
         $paginator = $commentRepository->getCommentPaginator($conference, $offset);

フォームを投稿すると、投稿された内容で Comment オブジェクトが更新されます。

フォームからは取り除きましたが、カンファレンスは、URL で既に決まっています。

フォームのバリデーションができなかった際は、ページを表示しますが、フォームには先程投稿した内容やエラーメッセージが入っているので、ユーザーに表示することができます。

フォームを試してみましょう。正しく動きデータベースに格納されるはずです(管理者バックエンドで確認してください)。しかし、まだ写真が正しく扱えていません。コントローラーで処理をしていないので正しく動きません。

ファイルをアップロードする

アップロードされた写真は、カンファレンスページで表示できるように Web からアクセスできるローカルのディスクに保存されるべきです。 public/uploads/photos ディレクトリ以下にしましょう:

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/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -9,6 +9,7 @@ use App\Repository\CommentRepository;
 use App\Repository\ConferenceRepository;
 use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\File\Exception\FileException;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Annotation\Route;
@@ -34,13 +35,22 @@ class ConferenceController extends AbstractController
     }

     #[Route('/conference/{slug}', name: 'conference')]
-    public function show(Request $request, Conference $conference, CommentRepository $commentRepository): Response
+    public function show(Request $request, Conference $conference, CommentRepository $commentRepository, string $photoDir): Response
     {
         $comment = new Comment();
         $form = $this->createForm(CommentFormType::class, $comment);
         $form->handleRequest($request);
         if ($form->isSubmitted() && $form->isValid()) {
             $comment->setConference($conference);
+            if ($photo = $form['photo']->getData()) {
+                $filename = bin2hex(random_bytes(6)).'.'.$photo->guessExtension();
+                try {
+                    $photo->move($photoDir, $filename);
+                } catch (FileException $e) {
+                    // unable to upload the photo, give up
+                }
+                $comment->setPhotoFilename($filename);
+            }

             $this->entityManager->persist($comment);
             $this->entityManager->flush();

写真のアップロードを行うのに、ファイルにランダムな名前を付けます。そして、アップロードされたファイルを写真格納ディレクトリの最終的な場所に移動します。そして、ファイル名はコメントオブジェクトに格納します。

show() メソッドに、サービスではなく文字列として $photoDir が引数として追加されています。Symfony のコンテナは、サービス以外に パラメーター を格納することができます。パラメーターは、サービスを設定するためのスカラー値です。これらのパラメーターは、明示的にサービスにインジェクトもできますし、名前で紐付かせる こともできます:

1
2
3
4
5
6
7
8
9
10
11
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -10,6 +10,8 @@ services:
     _defaults:
         autowire: true      # Automatically injects dependencies in your services.
         autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
+        bind:
+            string $photoDir: "%kernel.project_dir%/public/uploads/photos"

     # makes classes in src/ available to be used as services
     # this creates a service per class whose id is the fully-qualified class name

bind の設定があると、$photoDir 引数で Symfony が値をインジェクトしてくれます。

写真の代わりに PDF ファイルをアップロードしてみてください。エラーメッセージが表示されるはずです。デザインは適用していないのでかっこよくはないですが、Webサイトのデザインをする際に綺麗にします。フォームの全ての要素のスタイルを1行の設定で変更するようにします。

フォームをデバッグする

フォームが投稿された際に、何か問題があったときは、Symfony プロファイラーの "Form" パネルを使用してください。 "Form" パネルでは、オプションや投稿されたデータや内部的にどのように変換されたかなどフォームに関する情報を見ることができます。フォームにエラーがあれば、エラーを一覧として表示します。

一般的なフォームのワークフローは次のようになります:

  • フォームがページに表示されます;
  • ユーザーは POST リクエストでフォームを送信します;
  • サーバーは、ユーザーを他のページ、もしくは同じページにリダイレクトします。

成功時のフォーム投稿のプロファイラーはどうやってアクセスしたら良いでしょうか?ページは、リダイレクトされるので、POST リクエストでデバッグツールバーを使うことができませんが、ノープロブレムです。リダイレクトされた後のページの緑の "200" 部分の左位置をマウスオーバーしてください。"302" リダイレクトとプロファイラーへのリンクが見えるはずです。

/conference/amsterdam-2019

そこをクリックして POST リクエストのプロファイルへアクセスし、 "Form" パネルを見てみましょう:

1
$ rm -rf var/cache
/_profiler/450aa5

管理者のバックエンドにアップロードされた写真を表示する

まだ、管理者のバックエンドは、写真のファイル名が表示されていますので、実際の写真に変更しましょう:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
--- a/src/Controller/Admin/CommentCrudController.php
+++ b/src/Controller/Admin/CommentCrudController.php
@@ -9,6 +9,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
 use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
 use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
 use EasyCorp\Bundle\EasyAdminBundle\Field\EmailField;
+use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField;
 use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
 use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
 use EasyCorp\Bundle\EasyAdminBundle\Filter\EntityFilter;
@@ -45,7 +46,9 @@ class CommentCrudController extends AbstractCrudController
         yield TextareaField::new('text')
             ->hideOnIndex()
         ;
-        yield TextField::new('photoFilename')
+        yield ImageField::new('photoFilename')
+            ->setBasePath('/uploads/photos')
+            ->setLabel('Photo')
             ->onlyOnIndex()
         ;

アップロードされた写真を Git から除外する

Git リポジトリにアップロードされた画像を格納したくないので、まだコミットしないでください。 .gitignore ファイルに /public/uploads ディレクトリを追加してください:

1
2
3
4
5
6
7
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+/public/uploads

 ###> symfony/framework-bundle ###
 /.env.local

本番サーバーのアップロードされたファイルをソートする

最後のステップとして本番サーバーにアップロードされたファイルを格納するようにしましょう。何か特別なことをしないといけないのはなぜでしょうか。それは、ほとんどのクラウドプラットフォームは、読み込み権限のみのコンテナを使用しているからです。Platform.sh も例外ではありません。

Symfony プロジェクトにおいて全てが読み取り権限のみというわけではありません。コンテナを作成する際に(キャッシュウォームアップフェーズで)、可能な限り多くのキャッシュを生成するようにしますが、Symfony がユーザーキャッシュ、ログ、セッションなどをファイルシステムに保存している場合は書き込みできるようにする必要があります。

.platform.app.yaml を見てください。既に var/ ディレクトリは、書き込み可能で マウント することができるようになっています。この var/ ディレクトリが Symfony が書き込むことができる唯一のディレクトリです。ここにキャッシュやログが入ります。

アップロードされた写真のマウントを作成しましょう:

1
2
3
4
5
6
7
8
9
10
--- a/.platform.app.yaml
+++ b/.platform.app.yaml
@@ -35,6 +35,7 @@ web:

 mounts:
     "/var": { source: local, source_path: var }
+    "/public/uploads": { source: local, source_path: uploads }

 relationships:
     database: "database:postgresql"

これでコードをデプロイすれば、ローカルと同じように写真は public/uploads/ ディレクトリに格納されます。

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.
TOC
    Version