EC-CUBE研修資料(Symfony入門ハンズオン)

社内向けにEC-CUBE4の入門・トレーニングを行うことになったのでメモとして。

# 前提

他言語や他フレームワークである程度開発経験のある方向けの資料です。
EC-CUBEを把握するために、Symfonyの機能をざっくり理解していただくことを目的にしています。

# 動作環境

以下の環境で動作確認しています。

- Mac Catalina
- PHP 7.3.17
- SQLite3 3.28.0
- Google Chrome 88

# Symfonyの基礎

## セットアップ

symfonyコマンドの導入。
以下はMacの例。Winの場合は https://symfony.com/download を参照。

```
$ curl -sS https://get.symfony.com/cli/installer | bash
```

プロジェクトの作成。EC-CUBE4.0はSymfony3.4ベースのため、バージョンは3.4を指定。

```
symfony new my_project_name --version=3.4 --full
```

## ディレクトリ構成

ディレクトリ構成は以下の通り。

```
├── bin
├── config
├── migrations
├── public
├── src
│   ├── Controller
│   ├── Entity
│   ├── Form
│   └── Repository
├── templates
├── tests
├── translations
├── var
└── vendor
```

|ディレクトリ | 概要|
|--- | ---|
|bin | コンソールコマンド|
|config | 設定ファイル|
|migrations | マイグレーションファイル|
|public | ドキュメントルート|
|src | プロダクトコード|
|templates | twigテンプレート|
|tests | テストコード|
|translations | 言語ファイル|
|var | ログファイルやキャッシュ等|
|vendor | composerでインストールされる外部ライブラリ|

## 起動

Symfony Local Web Server で起動。

```
cd my_project_name
symfony serve
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72858/0ff0a395-b37a-b069-d6dd-e4cd93dea5fa.png)


## 簡単なCRUDアプリを作る

ここから簡易的なCRUDができるアプリを作っていきます。
Controller、Twig、FormType、Doctrine/ORMの使い方を学びます。

### 簡単な画面表示

まずは簡単な画面表示から。

```php:src/Controller/TodoController.php
<?php

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class TodoController
{
    /**
     * @Route("/todo", name="todo") # ※1
     */
    public function index() # ※2
    {
        return new Response('hello world'); # ※3
    }
}
```

/todoにアクセスし、hello worldが表示されればOK。

- ※1 パスの指定は`@Route`アノテーションで行う。
- ※1 第２引数のname属性は、twigテンプレート等から指定するとURLやパスに変換できる(後述)。
- ※2 メソッド名の命名はなんでもよい
- ※3 戻り値はResponseオブジェクト

### twigテンプレートの利用

Symfonyでは、テンプレートエンジンにtwigを利用しています。
出力結果をtwigを使ったテンプレートに置き換えます。

```twig:templates/todo.html.twig
hello world twig
```

```diff:src/Controller/TodoController.php
+use Symfony\Bundle\FrameworkBundle\Controller\Controller;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Annotation\Route;


-class TodoController
+class TodoController extends Controller # ※1
 {
     /**
      * @Route("/todo", name="todo")
      */
     public function index()
     {
-        return new Response('hello world');
+        return $this->render('todo.html.twig'); # ※2
     }
 }
```

/todoにアクセスし、hello world twigが表示されればOK。

- ※1 Controllerクラスを継承することで各種ユーティリティメソッドを追加できる
- ※2 render('テンプレート名')でtwigテンプレートで出力できる

### twigテンプレートの利用(変数、if、for等)

```diff:src/Controller/TodoController.php
     public function index()
     {
-        return $this->render('todo.html.twig'); # ※1
+        return $this->render('todo.html.twig', [
+            'message' => 'hello',
+            'nums' => [1,2,3],
+            'flg' => true,
+        ])
```

- 第２引数に key - valueで渡すことで、twigテンプレート内で変数として利用可能。

```diff:templates/todo.html.twig
-hello world twig
+{% if flg %}
+    {% for num in nums %}
+        {{ message }} world {{ num }}
+    {% endfor %}
+{% endif %}
```

- if, forなどは{% %}
- 変数出力は{{ value }}。デフォルトでエスケープされる。
- 詳しくは→ https://twig.symfony.com/

### twigテンプレートの利用(テンプレート継承)

```diff:templates/hello.html.twig
-{% if flg %}
-    {% for num in nums %}
-        {{ message }} world {{ num }}
-    {% endfor %}
-{% endif %}
+{% extends 'base.html.twig' %}
+
+{% block title %}
+    hello
+{% endblock %}
+
+{% block body %}
+    {% if flg %}
+        {% for num in nums %}
+            {{ message }} world {{ num }}
+        {% endfor %}
+    {% endif %}
+{% endblock %}
```

- 親のテンプレートを指定して共通化できる
- extendsで親テンプレートを指定
- 親テンプレートでblock定義されている箇所に差し込むことができる

### FormTypeを利用したフォーム作成(準備)

登録フォームを作成します。
登録画面のルーティングとtwigテンプレートを追加しておきます。

```diff:src/Controller/TodoController.php
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
 use Symfony\Bundle\FrameworkBundle\Controller\Controller;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Annotation\Route;
@@ -19,4 +20,13 @@ class TodoController extends Controller
             'flg' => true,
         ]);
     }
+
+    /**
+     * @Route("/todo/create", name="todo_create")
+     * @Template("create.html.twig")
+     */
+    public function create()
+    {
+        return [];
+    }
 }

```

※twigテンプレートの指定は、`@Template`アノテーションでも可能です。EC-CUBEはこちらで指定してる。
※このときの戻り値は配列

```twig:templates/create.html.twig
{% extends 'base.html.twig' %}

{% block title %}
    todo create
{% endblock %}

{% block body %}
    <form method="post">
        <input type="text" name="todo"/>
        <button type="submit">登録</button>
    </form>
{% endblock %}
```

### FormTypeを利用したフォーム作成(FormTypeの作成)

FormTypeを作成して、twigテンプレートから呼び出し

```php:src/Form/Type/TodoType.php
<?php

namespace App\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;

class TodoType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('todo', TextType::class, []);
    }
}
```

※input type="text"はTextTypeを利用
※その他のフォームタイプは https://symfony.com/doc/current/reference/forms/types.html を参照

```diff:src/Controller/TodoController.php 
+use App\Form\Type\TodoType;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
 use Symfony\Bundle\FrameworkBundle\Controller\Controller;
 use Symfony\Component\HttpFoundation\Response;

 class TodoController extends Controller
      */
     public function create()
     {
-        return [];
+        $form = $this->createForm(TodoType::class);
+
+        return [
+            'form' => $form->createView()
+        ];
     }
 }

```

```diff:templates/create.html.twig
 {% endblock %}
 
 {% block body %}
    <form method="post">
-        <input type="text" name="todo"/>
+        {{ form_widget(form.todo) }}
         <button type="submit">登録</button>
     </form>
 {% endblock %}

```

※form_widgetで画面描画できる

### FormTypeを利用したフォーム作成(バリデーション)

バリデーションを追加

```diff:src/Form/Type/TodoType
 use Symfony\Component\Form\Extension\Core\Type\TextType;
 use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Validator\Constraints\NotBlank;
 
 class TodoType extends AbstractType
 {
     public function buildForm(FormBuilderInterface $builder, array $options)
     {
-        $builder->add('todo', TextType::class, []);
+        $builder->add('todo', TextType::class, [
+            'required' => false,
+            'constraints' => [
+                new NotBlank()
+            ]
+        ]);
     }
 }
```

※constraintsオプションでバリデーションを追加できる
※required => falseはhtml5のバリデーション。ここではサーバへPOSTされないのでデモ用として無効化しているだけ。
※その他オプションは https://symfony.com/doc/4.0/validation.html を参照

```diff:templates/create.html.twig
     <form method="post">
         {{ form_widget(form.todo) }}
+        {{ form_errors(form.todo) }}
         <button type="submit">登録</button>
     </form>
```

※エラーメッセージを表示するために、form_errorsを使う

```diff:src/Controller/TodoController.php
 use App\Form\Type\TodoType;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
 use Symfony\Bundle\FrameworkBundle\Controller\Controller;
+use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Annotation\Route;
 
@@ -26,9 +27,14 @@ class TodoController extends Controller
      * @Route("/todo/create", name="todo_create")
      * @Template("create.html.twig")
      */
-    public function create()
+    public function create(Request $request)
     {
         $form = $this->createForm(TodoType::class);
+        $form->handleRequest($request);
+
+        if ($form->isSubmitted() && $form->isValid()) {
+            // do something
+        }
 
         return [
```

※フォームからのリクエストを、FormTypeにマッピングする

### FormTypeを利用したフォーム作成(日本語化とcsrf protection)

```diff:config/packages/translation.yaml
 framework:
-    default_locale: en
+    default_locale: ja
     translator:
         default_path: '%kernel.project_dir%/translations'
         fallbacks:

```

※エラーメッセージの日本語化

```diff:config/packages/framework.yaml
 framework:
     secret: '%env(APP_SECRET)%'
-    #csrf_protection: true
+    csrf_protection: true
     #http_method_override: true
```

```diff:templates/create.html.twig
 {% block body %}
     <form method="post">
+        {{ form_widget(form._token) }}
         {{ form_widget(form.todo) }}
         {{ form_errors(form.todo) }}
         <button type="submit">登録</button>
```

※csrf_protectionの有効化。form._tokenでトークン発行できる。

### Doctrine/ORMの利用(準備)

エンティティの作成、データベースの作成、テーブル作成を行う

```php:src/Entity/Todo.php

<?php

namespace App\Entity;

use App\Repository\TodoRepository;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=TodoRepository::class)
 */
class Todo
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $todo;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getTodo(): ?string
    {
        return $this->todo;
    }

    public function setTodo(string $todo): self
    {
        $this->todo = $todo;

        return $this;
    }
}
```

※`@ORM\Entity`で対象のテーブルを指定(上記では省略されてるが、テーブル名を指定することも可能)
※`@ORM\Column`でカラムのデータ型等を指定
※アノテーションの詳細は https://www.doctrine-project.org/projects/doctrine-orm/en/2.8/reference/annotations-reference.html を参照

```php:src/Repository/TodoRepository.php
<?php

namespace App\Repository;

use App\Entity\Todo;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @method Todo|null find($id, $lockMode = null, $lockVersion = null)
 * @method Todo|null findOneBy(array $criteria, array $orderBy = null)
 * @method Todo[]    findAll()
 * @method Todo[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class TodoRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Todo::class);
    }
}
```

※レポジトリを通じてデータの取得を行う

エンティティ・レポジトリを作成したら、DB・テーブル作成を行う

```sh:.env
DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db
```

※.envにsqliteのパスを追加

```sh
bin/console doctrine:database:create
```
※データベース作成

```sh
bin/console doctrine:schema:create
```
※テーブル作成

```sh
sqlite3 var/data.db

sqlite> insert into todo (todo) values ('todo1');
sqlite> insert into todo (todo) values ('todo2');
sqlite> insert into todo (todo) values ('todo3');

sqlite> select * from todo;
1|todo1
2|todo2
3|todo3
```
※3件ほどデータを追加しておく

### Doctrine/ORMの利用(Read)

最初に作成した、TodoController::indexで、todoリストを表示させる。

```diff:src/Controller/TodoController.php

 namespace App\Controller;
 
 use App\Form\Type\TodoType;
+use App\Repository\TodoRepository;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
 use Symfony\Bundle\FrameworkBundle\Controller\Controller;
 use Symfony\Component\HttpFoundation\Request;
@@ -11,16 +12,27 @@ use Symfony\Component\Routing\Annotation\Route;
 
 class TodoController extends Controller
 {
+    /**
+     * @var TodoRepository
+     */
+    private $todoRepository;
+
+    public function __construct(TodoRepository $todoRepository)
+    {
+        $this->todoRepository = $todoRepository;
+    }
+
     /**
      * @Route("/todo", name="todo")
+     * @Template("todo.html.twig")
      */
     public function index()
     {
-        return $this->render('todo.html.twig', [
-            'message' => 'hello',
-            'nums' => [1,2,3],
-            'flg' => true,
-        ]);
+        $todoList = $this->todoRepository->findBy([], ['id' => 'desc']);
+
+        return [
+            'todoList' => $todoList
+        ];
     }
 
     /**
```

※コンストラクタでTodoRepositoryをインジェクションする
※find/findAll/findBy等が使える


```diff:templates/todo.html.twig
 {% block body %}
-    {% if flg %}
-        {% for num in nums %}
-            {{ message }} world {{ num }}
-        {% endfor %}
-    {% endif %}
+    <ul>
+    {% for todo in todoList %}
+        <li>{{ todo.id }} - {{ todo.todo }}</li>
+    {% endfor %}
+    </ul>
 {% endblock %}
```

### Doctrine/ORMの利用(Create)

TodoController::createで登録画面を作成します。

```diff:src/Form/Type/TodoType.php
+use App\Entity\Todo;
 use Symfony\Component\Form\AbstractType;
 use Symfony\Component\Form\Extension\Core\Type\TextType;
 use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
 use Symfony\Component\Validator\Constraints\NotBlank;
 
 class TodoType extends AbstractType
@@ -18,4 +20,11 @@ class TodoType extends AbstractType
             ]
         ]);
     }
+
+    public function configureOptions(OptionsResolver $resolver)
+    {
+        $resolver->setDefaults([
+            'data_class' => Todo::class
+        ]);
+    }
 }
```

フォームからリクエストされた値をエンティティにマッピングするため、TodoTypeでdata_classを指定する

```diff:src/Controller/TodoController.php
+use App\Entity\Todo;
 use App\Form\Type\TodoType;
 use App\Repository\TodoRepository;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
@@ -41,11 +42,18 @@ class TodoController extends Controller
      */
     public function create(Request $request)
     {
-        $form = $this->createForm(TodoType::class);
+        $form = $this->createForm(TodoType::class, new Todo()); # ※1
         $form->handleRequest($request);
 
         if ($form->isSubmitted() && $form->isValid()) {
-            // do something
+            $todo = $form->getData(); # ※2
+            $entityManager = $this->getDoctrine()->getManager(); # ※3
+            $entityManager->persist($todo); # ※4
+            $entityManager->flush(); # ※5
+
+            $this->addFlash('success', '登録しました。'); # ※6
+
+            return $this->redirectToRoute('todo_create');
         }
```

※1 Todoエンティティの空オブジェクトを渡す
※2 $form->getData()で、Todoエンティティにフォームからのリクエストをセットした状態で取得できる
※3 Doctrineのエンティティマネージャを取得
※4 persist()でエンティティが永続化対象であることをDoctrineに認識させる
※5 flush()でSQL発行
※6 画面へ成功メッセージを出力

```diff:templates/create.html.twig
 {% endblock %}
 
 {% block body %}
+    {% for message in app.flashes('success') %}
+        <div class="alert alert-success">
+            {{ message }}
+        </div>
+    {% endfor %}
     <form method="post">
         {{ form_widget(form._token) }}
         {{ form_widget(form.todo) }}
         {{ form_errors(form.todo) }}
         <button type="submit">登録</button>
     </form>
+    <a href="{{ url('todo') }}">一覧へ戻る</a>
 {% endblock %}
```

成功メッセージを出力。
ついでに一覧画面へのリンクを追加。url()関数にルーティング名を渡すとurlを生成できる。

### Doctrine/ORMの利用(Update)

TodoController::createを、編集もできるように拡張していきます。

```diff:src/Controller/TodoController.php
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 use Symfony\Component\Routing\Annotation\Route;
 
 class TodoController extends Controller
@@ -38,11 +39,20 @@ class TodoController extends Controller
 
     /**
      * @Route("/todo/create", name="todo_create")
+     * @Route("/todo/{id}/edit", name="todo_edit", requirements={"id" = "\d+"}) # ※1
      * @Template("create.html.twig")
      */
-    public function create(Request $request)
+    public function create(Request $request, $id = null) # ※2
     {
-        $form = $this->createForm(TodoType::class, new Todo());
+        if ($id === null) {
+            $todo = new Todo();
+        } else {
+            $todo = $this->todoRepository->find($id); # ※3
+            if ($todo === null) {
+                throw new NotFoundHttpException();
+            }
+        }
+        $form = $this->createForm(TodoType::class, $todo); # ※4
         $form->handleRequest($request);
 
         if ($form->isSubmitted() && $form->isValid()) {
@@ -51,13 +61,15 @@ class TodoController extends Controller
             $entityManager->persist($todo);
             $entityManager->flush();
 
-            $this->addFlash('success', '登録しました。');
+            $operation = $id ? '更新' : '登録';
+            $this->addFlash('success', $operation.'しました。');
 
-            return $this->redirectToRoute('todo_create');
+            return $this->redirectToRoute('todo_edit', ['id' => $todo->getId()]);
         }
 
         return [
-            'form' => $form->createView()
+            'form' => $form->createView(),
+            'todo' => $todo,
         ];
```

※1※2 編集用のルーティングを追加。`{id}`でコントローラの引数としてあつかえる。`requirements={"id" = "\d+"}`で文字種を数値のみに限定。
※3 編集時はDBからデータを取得
※4 DBから取得したデータをフォームへ渡す。これで画面描画した際に、初期値として設定できる。

```diff:/templates/create.html.twig
             {{ message }}
         </div>
     {% endfor %}
-    <form method="post">
+    <form method="post" action="{{ todo.id ? url('todo_edit', { id: todo.id }) : url('todo_create') }}">
         {{ form_widget(form._token) }}
         {{ form_widget(form.todo) }}
         {{ form_errors(form.todo) }}
```

登録フォームを編集時でも使えるように、idの有無でPOST先を切り替え

```diff:templates/todo.html.twig
 
 {% block body %}
+    <a href="{{ url('todo_create') }}">新規登録</a>
     <ul>
     {% for todo in todoList %}
-        <li>{{ todo.id }} - {{ todo.todo }}</li>
+        <li>{{ todo.id }} - <a href="{{ url('todo_edit', { id: todo.id }) }}">{{ todo.todo }}</a></li>
     {% endfor %}
     </ul>
```

登録画面、編集画面への導線を追加


編集時のコントローラの流れをざっくり説明すると以下のとおり。

```php
    // id = 123 のtodoを編集する場合
    // フォームからsubmitすると/todo/123/editにPOSTされ、引数$idに123がセットされる
    public function create(Request $request, $id = null)
    {
        if ($id === null) {
            $todo = new Todo();
        } else {
            // id = 123 をDBから検索
            $todo = $this->todoRepository->find($id);
            if ($todo === null) {
                throw new NotFoundHttpException();
            }
        }
        $form = $this->createForm(TodoType::class, $todo);
        // POSTされた値を、todoエンティティのプロパティに反映
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // 反映後のtodoエンティティを取得
            $todo = $form->getData();
            $entityManager = $this->getDoctrine()->getManager();
            $entityManager->persist($todo);
            // エンティティが更新されている場合、UPDATE文をDBに発行
            $entityManager->flush();
```

### Doctrine/ORMの利用(Delete)

削除機能を作ります。

```diff:
 use Symfony\Bundle\FrameworkBundle\Controller\Controller;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 use Symfony\Component\Routing\Annotation\Route;
 
@@ -72,4 +73,26 @@ class TodoController extends Controller
             'todo' => $todo,
         ];
     }
+
+    /**
+     * @Route("/todo/{id}/remove", name="todo_remove", requirements={"id" = "\d+"}, methods={"POST"}) # ※1
+     */
+    public function remove(Request $request, $id)
+    {
+        $token = $request->headers->get('x-csrf-token'); # ※2
+        if (!$this->isCsrfTokenValid('x-csrf-token', $token)) {
+            throw new BadRequestHttpException();
+        }
+
+        $todo = $this->todoRepository->find($id);
+        if ($todo == null) {
+            throw new NotFoundHttpException();
+        }
+
+        $entityManager = $this->getDoctrine()->getManager();
+        $entityManager->remove($todo);
+        $entityManager->flush();
+
+        return $this->json(['success' => true]);
+    }
```

※1 HTTP METHODを指定したいときはMethod={}で指定する。EC-CUBEでは、削除系はDELETEメソッドを指定している。
※2 ajax処理の際のCSFRトークンチェックはこのような形で。

```diff:templates/todo.html.twig
     hello
 {% endblock %}
+{% block javascripts %}
+    <script>
+        window.onload = event => {
+            const btns = document.querySelectorAll('button')
+            btns.forEach((btn) => {
+                btn.addEventListener('click', event => {
+                    if (!window.confirm('削除しますか？')) {
+                        return;
+                    }
+                    fetch(btn.dataset.url, {
+                        method: 'post',
+                        headers: {
+                            'x-csrf-token': '{{ csrf_token('x-csrf-token') }}'
+                        }
+                    })
+                    .then((res) => {
+                        if (res.ok) {
+                            alert('削除しました')
+                            btn.parentElement.remove();
+                        } else {
+                            alert('削除に失敗しました')
+                        }
+                    })
+                })
+            })
+        }
+    </script>
+{% endblock %}
+
 {% block body %}
     <a href="{{ url('todo_create') }}">新規登録</a>
     <ul>
     {% for todo in todoList %}
-        <li>{{ todo.id }} - <a href="{{ url('todo_edit', { id: todo.id }) }}">{{ todo.todo }}</a></li>
+        <li>{{ todo.id }} - <a href="{{ url('todo_edit', { id: todo.id }) }}">{{ todo.todo }}</a>
+            <button type="button" data-url="{{ url('todo_remove', { id: todo.id }) }}">削除</button>
+        </li>
     {% endfor %}
     </ul>
 {% endblock %}

```

※ EC-CUBE 4.0では IE11がシステム要件に入っているので constとかは使えないので注意

## 参考文献・資料
- https://www.shuwasystem.co.jp/book/9784798056692.html
- https://symfony.com/doc/4.0/index.html
    - ※Symfony3.4は過渡期のバージョンのため、EC-CUBEで適用できないことが多い。4.0のドキュメントを参照するのがよい
- https://twig.symfony.com/
- https://www.doctrine-project.org/projects/doctrine-orm/en/2.8/index.html

# EC-CUBE4の基礎

TODO