Symfonyで簡単なCRUDを作る

この記事は [カオナビ Advent Calendar 2024](https://qiita.com/advent-calendar/2024/kaonavi "株式会社カオナビ - Qiita Advent Calendar 2024 - Qiita") に書くのが間に合わなかった記事になります。ちゃんと埋まってよかったね。

## はじめに

はいさい。[しまぶ@shimabox](https://x.com/shimabox "しまぶ（@shimabox） / X") だよ。

というわけで本を書きました。こちらです。以降、カオナビ本と呼びますね。

https://www.amazon.co.jp/dp/4798073229

本を書いた経緯などは、こちらが詳しいです。

https://kunit.hatenablog.jp/entry/2024/12/02/131002

ありがたいことに、レビューを書いてくれている人もちらほら見かけています。ほんまにありがてぇ。

https://note.com/012xx_/n/n81a621cdca49

https://blog.magnolia.tech/entry/2024/12/14/220517

https://heartyfluid.hatenablog.com/entry/2024/12/20/002112

さて、このカオナビ本では、フレームワークの紹介としてSymfonyのサンプルコードを載せています。(Chapter05 モダンなPHPフレームワーク)
ただ、紙面の都合で一部を省略しているんですよね。つまり、そのまま写経しても動かないんです。なんだか少し残念ですよね。

そこで今回はカオナビ本を片手に、Symfonyのサンプルコードを動かすところまでを、チュートリアル形式で一緒に進めていきたいと思います。

つまり、この記事のタイトルを正確に書くのなら

**カオナビ本を片手にSymfonyで簡単なCRUDを作る**

となります！

それでは、いってみましょう！レッツゴー！

## 前提条件

- GitHubアカウントがある
- Dockerが利用できる
  - https://github.com/php-tech-master24/devenv を利用します
  - [環境構築手順](https://github.com/php-tech-master24/devenv/blob/main/README.md#%E7%92%B0%E5%A2%83%E6%A7%8B%E7%AF%89%E6%89%8B%E9%A0%86) が終わっていればOKです

## つくるもの

カオナビ本でいうところの、

- Chapter05 モダンなPHPフレームワーク
  - 05-04 Symfony

が、該当します。以下のタスク一覧画面を作成します。

### タスク一覧画面
- タスクのタイトルと内容が書き込める
- タスクのタイトルは必須、内容は任意
- 登録したタスクは編集ができる
- 登録したタスクの一覧を確認できる
- タスクの削除ができる 

## 今回の作業ログ

最初に貼っときます。今回、作業したリポジトリのログは以下になります。

https://github.com/shimabox/devenv/commits/main/

## 環境を用意(Forkする)

まず環境を用意しましょう。こちらをForkします。

https://github.com/php-tech-master24/devenv

### Forkする

1. https://github.com/php-tech-master24/devenv にアクセスしたら右上の`Fork`をクリックします。  
![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9615/740d7de9-bc80-c070-69d9-4a5d803b9f36.png)

2. 次に、`Create fork`をクリックします。  
![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9615/6acf225d-1ef1-cb91-20f7-0c13d295560e.png)

3. このように、forked from php-tech-master24/devenv となっていればOKです。  
![3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9615/c591ed77-11a4-5ecf-4fa2-03e44587eebf.png)

4. 以降、こちらのリポジトリをつかいます

### 作業ディレクトリを作る

ローカルに作業ディレクトリを作ります。
```shell
mkdir sandbox && cd sandbox
```

こちらで作ったローカルのsandboxディレクトリを、**作業ディレクトリ**とします。

:::note info
作業ディレクトリの名前はなんでもいいです
:::

### Cloneする

作業ディレクトリ内でclone
```shell
git clone https://github.com/shimabox/devenv.git # shimaboxのところは自身のアカウント名に変えてください
```  

移動
```shell
cd devenv
```

### 環境を起動

```shell
make up
```

こんな感じになっていればOK。

```shell
docker ps

CONTAINER ID   IMAGE        COMMAND                  CREATED         STATUS         PORTS                               NAMES
be0409416857   devenv-web   "docker-php-entrypoi…"   9 seconds ago   Up 8 seconds   0.0.0.0:8080->80/tcp                web
a41cbfc9c9da   mysql:8.0    "docker-entrypoint.s…"   10 days ago     Up 10 days     0.0.0.0:3306->3306/tcp, 33060/tcp   db
```

:::note warn
以降、作業ディレクトリやコンテナー(App, DB)の中で作業をしていきます。
ターミナルは、別タブや別windowなどで複数開いておきましょう。
:::

### Appコンテナーの中に入る

```shell
make php-cli
```

ここにくる
```shell
root@be0409416857:/var/www/html#
```

ディレクトリはこうなっている
```shell
ls

chapter02  chapter09  chapter12  composer.json  composer.lock  phpunit.xml
```

`composer`が入っているか確認
```shell
which composer

/usr/bin/composer
```

ここまで確認できればGoooood。

## Symfonyをインストール

コンテナーの中で以下の作業を行っていきます。

### composer install

```shell
composer create-project symfony/skeleton symfony-sample
```

移動
```shell
cd symfony-sample
```

**ディレクトリ構成を確認**
```shell
ls

bin  composer.json  composer.lock  config  public  src  symfony.lock  var  vendor
```

### 各種Symfonyコンポーネントやバンドルをまとめてインストール

```shell
composer require webapp
```

webappパッケージをインストールすることで、以下のパッケージがまとめてインストールされます。

- Twig
- Doctrine ORM
- Symfony MakerBundle
- その他の必要なパッケージ

このコマンドについては、こちらが詳しいです。  
[「Symfonyをインストール」するとき、具体的に何が行われているのか](https://zenn.dev/ttskch/articles/0d1c98e88d811d#composer-require-webapp-%E3%82%84---webapp-%E3%81%AE%E6%84%8F%E5%91%B3 "「Symfonyをインストール」するとき、具体的に何が行われているのか")

:::note warn
Dockerの設定をするか？という質問をされるので、n(No)と答えておきます。  
```shell
Do you want to include Docker configuration from recipes?
[y] Yes
[n] No
[p] Yes permanently, never ask again for this project
[x] No permanently, never ask again for this project
(defaults to y): n
```
:::

後は、がーっといろいろ行われると思います。最後まで実行されればOKです。

**ディレクトリ構成を確認**

```shell
ls

assets  bin  composer.json  composer.lock  config  importmap.php  migrations  phpunit.xml.dist  public  src  symfony.lock  templates  tests  translations  var  vendor
```

## 起動してみる

ここまでで、とりあえず起動してみます。
作業ディレクトリに移動します。Appコンテナーの中から抜けてください。

```shell
root@be0409416857:/var/www/html/symfony-sample# exit # exitで抜ける
exit
~/shimabox/sandbox/devenv (main)
%
```

### Webサーバーを起動

**Symfony CLI をインストールする**

[Symfony CLI](https://symfony.com/download) はSymfonyをローカルで開発する際に利用できるコマンドラインツールです。いろいろと便利になるので、実際に開発する際は入れておいた方がいいです。

https://qiita.com/ippey_s/items/8919f618d13b3b6242e9

今回はこちらを利用します。

- Mac
  ```shell
  brew install symfony-cli/tap/symfony-cli
  ```
- Windows(試していません)
  ```shell
  scoop install symfony-cli
  ```

**Webサーバーを起動**

`devenv`にいると仮定します。

`symfony server:start --dir=app/symfony-sample/public` を実行します。

```shell
symfony server:start --dir=app/symfony-sample/public

〜

 [OK] Web server listening
      The Web server is using PHP CGI 8.3.9
      http://127.0.0.1:8000

```

:::note info
デフォルトのportは`8000`です。
:::

**アクセス**

http://localhost:8000 (http://127.0.0.1:8000) にアクセスしてみます。

![CleanShot 2024-12-30 at 09.50.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9615/4f7d7bb8-7f71-8836-913d-03b726602e4c.png)

Goooood!!!

## Symfonyの起動までをコミット

Symfonyの起動まで確認できたので、ここでいったんコミットしましょう。
```shell
git add .
git commit -m "feat: Symfonyの起動まで"
```

**作業ログ**

https://github.com/shimabox/devenv/commit/cc826c928f555e7e74c27275b096ef1bfff39ab9

## DBを用意する

CRUDを作っていきたいので、まずDBを用意します。
DBは`MySQL8.0`を使います。

### DBの確認

DBコンテナーの中に入ります。
別タブや、別のwindowでターミナルなどを起動しましょう。

**作業ディレクトリの中にいると仮定(いなかったら移動してください)**
```shell
cd sandbox/devenv
```

**DBコンテナーの中に入る**
```shell
docker compose exec db /bin/bash
```

**MySQLのバージョンを確認**
```shell
mysql --version

mysql  Ver 8.0.37 for Linux on aarch64 (MySQL Community Server - GPL)
```

**MySQLへ接続**

DBの接続情報は以下になります。
[compose.yml](https://github.com/php-tech-master24/devenv/blob/main/compose.yaml#L12-L23) を参照

- host
  - 127.0.0.1
- port
  - 3306
- database
  - sample
- user
  - myuser
- passward
  - mypassword

```shell
mysql -h 127.0.0.1 -u myuser -p sample
Enter password: 
```

**中身を見る(まだ空です)**
```shell
mysql> show tables;
Empty set (0.01 sec)
```

DBの確認が済んだらGoooood.

### .env.local の作成

続いて`.env.local`ファイルを用意します。

.envファイルは環境変数を定義しておくものになります。
ここへ環境ごとに読み込む値をセットしていきます。

今回はローカル環境なので、`.env.local`を用意します。
`.env.local`にセットされた値は、`.env`よりも優先されます。

:::note info
参考
- [Symfonyの環境変数の扱いについてまとめたよ | QUARTETCOM TECH BLOG](https://tech.quartetcom.co.jp/2021/03/08/symfony-env-manual/#%E7%B5%90%E8%AB%96 "Symfonyの環境変数の扱いについてまとめたよ | QUARTETCOM TECH BLOG")
  ```
  .env < .env.local < .env.$APP_ENV < .env.$APP_ENV.local < マシンに設定されている環境変数
  ```
:::

**Appコンテナーの中に入る**

```shell
make php-cli
cd symfony-sample/
```

**.env.local の作成**

```shell
touch .env.local
```

**DB接続情報を書き込む**

```shell
vim .env.local
```

`DATABASE_HOST`と`DATABASE_URL`を用意します。
```
DATABASE_HOST="127.0.0.1"
DATABASE_URL="mysql://myuser:mypassword@${DATABASE_HOST}:3306/sample?serverVersion=8.0.37"
```

**環境変数の確認**

```shell
php bin/console debug:container --env-vars

Symfony Container Environment Variables
=======================================

 ------------------------- ------------------ -----------------------------------------------------------------
  Name                      Default value      Real value
 ------------------------- ------------------ -----------------------------------------------------------------
  APP_SECRET                n/a                "5abb6e6abc1a0ac00fe0b74d3a6767fe"
  DATABASE_URL              n/a                "mysql://myuser:mypassword@127.0.0.1:3306/sample?serverVersion=8.0.37"
  MAILER_DSN                n/a                "null://null"
  MESSENGER_TRANSPORT_DSN   n/a                "doctrine://default?auto_setup=0"
  VAR_DUMPER_SERVER         "127.0.0.1:9912"   n/a
 ------------------------- ------------------ -----------------------------------------------------------------

 // Note real values might be different between web and CLI.
```

`DATABASE_URL`が設定された値になっているか確認しましょう。
`APP_SECRET`は、.env.dev の値が読み込まれていてランダムな値が表示されているはずです。

この後は、
1. エンティティ(リポジトリ)の作成  
1. マイグレーションファイルの生成
1. マイグレーションファイルの適用

を行って、DBにテーブルを作成していきます。

:::note warn
なぜ、`DATABASE_HOST`をわけているのか？

今回ホストOS側でPHPを実行(symfony server:start)するので、デフォルトはlocalhost(127.0.0.1)にしています。ですが、DBのマイグレーションはコンテナーの中で行うのでlocalhost(127.0.0.1)だと解決できません。

それを解消するためにDATABASE_HOSTをわけています。

DBのマイグレーションを行う際は、`DATABASE_HOST="db"`として実行します。
db:3306 のdbはDBコンテナーの名前です
:::

**環境変数の確認(コンテナ内での実行用)**

`DATABASE_HOST="db" php Bin/console debug:container --env-vars`のように、先頭に`DATABASE_HOST="db"`をつけて、このコマンド実行時のみ環境変数を書き換えています。

```shell
DATABASE_HOST="db" php Bin/console debug:container --env-vars

Symfony Container Environment Variables
=======================================

 ------------------------- ------------------ -----------------------------------------------------------------
  Name                      Default value      Real value
 ------------------------- ------------------ -----------------------------------------------------------------
  APP_SECRET                n/a                "5abb6e6abc1a0ac00fe0b74d3a6767fe"
  DATABASE_URL              n/a                "mysql://myuser:mypassword@db:3306/sample?serverVersion=8.0.37"
  MAILER_DSN                n/a                "null://null"
  MESSENGER_TRANSPORT_DSN   n/a                "doctrine://default?auto_setup=0"
  VAR_DUMPER_SERVER         "127.0.0.1:9912"   n/a
 ------------------------- ------------------ -----------------------------------------------------------------

 // Note real values might be different between web and CLI.
```

## エンティティ(リポジトリ)の作成

そのままAppコンテナーの中で作業します。

### Taskエンティティを作成

`php bin/console make:entity Task`を実行します。

```shell
php bin/console make:entity Task

created: src/Entity/Task.php
created: src/Repository/TaskRepository.php

Entity generated! Now let's add some fields!
You can always add more fields later manually or by re-running this command.
```

プロンプトに従って、以下のフィールドを追加します。

- id (integer) 
- title (string)
  - フィールドの長さ: 255
  - Not Null
- description (text)
  - Default Null

<details><summary>プロンプト</summary>

```shell
New property name (press <return> to stop adding fields):
 > title

 Field type (enter ? to see all types) [string]:
 > string

 Field length [255]:
 > 255

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 updated: src/Entity/Task.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > description

 Field type (enter ? to see all types) [string]:
 > text

 Can this field be null in the database (nullable) (yes/no) [no]:
 > yes

 updated: src/Entity/Task.php

 Add another property? Enter the property name (or press <return> to stop adding fields):
 >

  Success!

 Next: When you're ready, create a migration with php bin/console make:migration
```

:::note warn
id はデフォルトで作成されます
:::

</details>

### Taskエンティティの確認

`app/symfony-sample/src/Entity/Task.php`が作成されています。

```php
<?php

namespace App\Entity;

use App\Repository\TaskRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: TaskRepository::class)]
class Task
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $title = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $description = null;

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

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function setTitle(string $title): static
    {
        $this->title = $title;

        return $this;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function setDescription(?string $description): static
    {
        $this->description = $description;

        return $this;
    }
}

```

`app/symfony-sample/src/Repository/TaskRepository.php` は以下になります。

<details><summary>TaskRepository</summary>

```php
<?php

namespace App\Repository;

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

/**
 * @extends ServiceEntityRepository<Task>
 */
class TaskRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Task::class);
    }

    //    /**
    //     * @return Task[] Returns an array of Task objects
    //     */
    //    public function findByExampleField($value): array
    //    {
    //        return $this->createQueryBuilder('t')
    //            ->andWhere('t.exampleField = :val')
    //            ->setParameter('val', $value)
    //            ->orderBy('t.id', 'ASC')
    //            ->setMaxResults(10)
    //            ->getQuery()
    //            ->getResult()
    //        ;
    //    }

    //    public function findOneBySomeField($value): ?Task
    //    {
    //        return $this->createQueryBuilder('t')
    //            ->andWhere('t.exampleField = :val')
    //            ->setParameter('val', $value)
    //            ->getQuery()
    //            ->getOneOrNullResult()
    //        ;
    //    }
}

```
</details>

#### エンティティ(リポジトリ)の作成までをコミット

エンティティ(リポジトリ)の作成まで確認できたので、ここでいったんコミットしましょう。
作業ディレクトリで行います。
```shell
git add src/Entity/Task.php src/Repository/TaskRepository.php
git commit -m "feat: エンティティ(リポジトリ)の作成"
```

**作業ログ**

https://github.com/shimabox/devenv/commit/172cfc84a275fb064211bfeeb97674a48b8696b5

### マイグレーションファイルの生成

`DATABASE_HOST="db" php bin/console make:migration`を実行します。

```shell
DATABASE_HOST="db" php bin/console make:migration

created: migrations/Version20241229070020.php

  Success!

Review the new migration then run it with php bin/console doctrine:migrations:migrate
See https://symfony.com/doc/current/bundles/DoctrineMigrationsBundle/index.html
```

### マイグレーションファイルの適用

`DATABASE_HOST="db" php bin/console doctrine:migrations:migrate`を実行します。

```shell
DATABASE_HOST="db" php bin/console doctrine:migrations:migrate

 WARNING! You are about to execute a migration in database "sample" that could result in schema changes and data loss. Are you sure you wish to continue? (yes/no) [yes]:
 > yes

[notice] Migrating up to DoctrineMigrations\Version20241229070020
[notice] finished in 25.3ms, used 24M memory, 1 migrations executed, 2 sql queries

 [OK] Successfully migrated to version: DoctrineMigrations\Version20241229070020
```

:::note warn

```shell
WARNING! You are about to execute a migration in database "sample" that could result in schema changes and data loss. Are you sure you wish to continue? (yes/no) [yes]:
 > yes
```
ほんまにこれでいいんでっか？と、聞かれますが `yes`と答えましょう。
気になる方は、
```shell
php bin/console doctrine:migrations:migrate --no-interaction
```
のように、`--no-interaction`をつければ聞かれることはありません。
:::

### テーブルの確認

DBコンテナーの中で、DBにつないでテーブルを確認してみましょう。

```shell
docker compose exec db /bin/bash

mysql -h 127.0.0.1 -u myuser -p sample

mysql> desc task;
+-------------+--------------+------+-----+---------+----------------+
| Field       | Type         | Null | Key | Default | Extra          |
+-------------+--------------+------+-----+---------+----------------+
| id          | int          | NO   | PRI | NULL    | auto_increment |
| title       | varchar(255) | NO   |     | NULL    |                |
| description | longtext     | YES  |     | NULL    |                |
+-------------+--------------+------+-----+---------+----------------+
3 rows in set (0.01 sec)
```

Goooood.

#### マイグレーションファイルの適用までをコミット

マイグレーションファイルの適用まで確認できたので、ここでいったんコミットしましょう。
作業ディレクトリで行います。
```shell
git add migrations/.
git commit -m "feat: マイグレーションファイルの生成" 
```

**作業ログ**

https://github.com/shimabox/devenv/commit/7442bc4df4242b5ba28e7b08724178d24aec5ca0

### エンティティの修正

:::note warn
[追記 (2024/12/31)](https://qiita.com/shimabox/items/0197645f35fa0c64d929#%E8%BF%BD%E8%A8%98-20241231) にあるとおり、こちらの作業はやらなくても大丈夫です！すっ飛ばしてこ！
:::

Taskエンティティのidとtitleのアクセサは、Not Nullでいきたいので`src/Entity/Task.php`を修正します。

```shell
git diff

diff --git a/app/symfony-sample/src/Entity/Task.php b/app/symfony-sample/src/Entity/Task.php
index 4c802d1..e330414 100644
--- a/app/symfony-sample/src/Entity/Task.php
+++ b/app/symfony-sample/src/Entity/Task.php
@@ -12,20 +12,20 @@ class Task
     #[ORM\Id]
     #[ORM\GeneratedValue]
     #[ORM\Column]
-    private ?int $id = null;
+    private int $id;

     #[ORM\Column(length: 255)]
-    private ?string $title = null;
+    private string $title;

     #[ORM\Column(type: Types::TEXT, nullable: true)]
     private ?string $description = null;

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

-    public function getTitle(): ?string
+    public function getTitle(): string
     {
         return $this->title;
     }
```

<details><summary>修正版Taskエンティティ</summary>

```php
<?php

namespace App\Entity;

use App\Repository\TaskRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: TaskRepository::class)]
class Task
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $title = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $description = null;

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

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function setTitle(string $title): static
    {
        $this->title = $title;

        return $this;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function setDescription(?string $description): static
    {
        $this->description = $description;

        return $this;
    }
}

```

</details>

#### Taskエンティティの修正をコミット

修正したのでコミットしましょう。
作業ディレクトリで行います。
```shell
git add src/Entity/Task.php
git commit -m "feat: nullableをやめる"
```

**作業ログ**

https://github.com/shimabox/devenv/commit/c2851442058139e76772241d20714b5fd654634b

## CRUD

CRUDをひとつひとつ作っていきます。

1. Read
1. Create
1. Update
1. Delete

の順に作成していきます。Appコンテナーの中で作業します。

:::note info
```shell
php bin/console make:crud Task
```
で自動生成できますが、今回はひとつひとつ作成していきます。
なるべくカオナビ本をコピペしてくのです！
:::

### Controller

まずコントローラを作ります。

`app/symfony-sample/src/Controller/TaskController.php`

```php
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class TaskController extends AbstractController
{
}

```

### Read

では、CRUDのRを作っていきます。タスク一覧です。

`task_index`のルートを作成します。

#### Controllerの修正

```php
<?php

namespace App\Controller;

use App\Entity\Task;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class TaskController extends AbstractController
{
    #[Route('/task', name: 'task_index', methods: ['GET'])]
    public function index(EntityManagerInterface $em): Response
    {
        $tasks = $em->getRepository(Task::class)->findAll();
        return $this->render('task/index.html.twig', ['tasks' => $tasks]);
    }
}

```

Attributeを利用したルーティングを行っています。`config/routes.yaml`で確認できます。

```yml
controllers:
    resource:
        path: ../src/Controller/
        namespace: App\Controller
    type: attribute
```

#### Viewの作成

`app/symfony-sample/templates/task/index.html.twig` を作成します。

```twig
{# templates/task/index.html.twig #}

{% extends 'base.html.twig' %}

{% block title %}タスク一覧{% endblock %}

{% block body %}
    <h1>タスク一覧</h1>
    <ul>
        {% for task in tasks %}
            <li>
                {{ task.title }}
            </li>
        {% else %}
            <li>タスクがありません。</li>
        {% endfor %}
    </ul>
{% endblock %}

```

タスクがあれば、`タスクのタイトル`を表示して、なければ`タスクがありません。`と表示します。

#### 確認

http://localhost:8000/task にアクセスして確認します。

##### タスクがないとき

![CleanShot 2024-12-30 at 10.39.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9615/ecf566b3-e426-3032-4d64-fbb66e3424a1.png)

##### タスクがあるとき

まだタスクが無いので作ります。
DBコンテナーからDBに繋いで、以下クエリを流します。

`devenv`にいると仮定します
```shell
docker compose exec db /bin/bash
```

DBに接続
```shell
mysql -h 127.0.0.1 -u myuser -p sample
Enter password: 
```

以下のクエリを流す
`INSERT INTO task (title, description) VALUES ('Task 1', 'This is task1.'), ('Task 2', 'This is task2.'), ('Task 3', 'This is task3.');`

```sql
mysql> INSERT INTO task (title, description) VALUES ('Task 1', 'This is task1.'), ('Task 2', 'This is task2.'), ('Task 3', 'This is task3.');
Query OK, 3 rows affected (0.00 sec)
Records: 3  Duplicates: 0  Warnings: 0

mysql> select * from task;
+----+--------+----------------+
| id | title  | description    |
+----+--------+----------------+
|  1 | Task 1 | This is task1. |
|  2 | Task 2 | This is task2. |
|  3 | Task 3 | This is task3. |
+----+--------+----------------+
3 rows in set (0.00 sec)
```

http://localhost:8000/task に再度アクセス。

![CleanShot 2024-12-30 at 10.59.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9615/21675346-cee2-8a46-4464-1fa131f83dda.png)

Goooood.

#### Readの作成までをコミット

Readの作成まで確認できたので、ここでいったんコミットしましょう。
```shell
git add app/symfony-sample/src/Controller/TaskController.php app/symfony-sample/templates/task/
git commit -m "feat: Readの作成まで"
```

**作業ログ**

https://github.com/shimabox/devenv/commit/e329b2b1ffbb6319d463a00c72d1d4e062ec186f

### Create

続いて、CRUDのCを作っていきます。タスクの登録です。

#### Controllerの修正

`task_new`のルートを作成します。

```php
<?php

namespace App\Controller;

use App\Entity\Task;
use App\Form\TaskType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class TaskController extends AbstractController
{
    // 〜

    #[Route('/task/new', name: 'task_new', methods: ['GET', 'POST'])]
    public function new(Request $request, EntityManagerInterface $em): Response
    {
        $task = new Task();
        $form = $this->createForm(TaskType::class, $task);

        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $em->persist($task);
            $em->flush();

            return $this->redirectToRoute('task_index');
        }

        return $this->render('task/new.html.twig', ['form' => $form->createView()]);
    }
}

```

#### Formの作成

`app/symfony-sample/src/Form/TaskType.php`

```php
<?php

namespace App\Form;

use App\Entity\Task;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class TaskType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title', TextType::class, [
                'label' => 'Title',
            ])
            ->add('description', TextareaType::class, [
                'label' => 'Description',
                'required' => false,
            ]);
    }

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

```

`description`は、`'required' => false,` にして任意としています。


#### Viewの作成と修正

`app/symfony-sample/templates/task/new.html.twig` を作成します。

```twig
{# templates/task/new.html.twig #}

{% extends 'base.html.twig' %}

{% block title %}新しいタスクを作成{% endblock %}

{% block body %}
    <h1>新しいタスクを作成</h1>

    {{ form_start(form) }}
    {{ form_widget(form) }}
    <button type="submit">作成</button>
    {{ form_end(form) }}

    <a href="{{ path('task_index') }}">戻る</a>
{% endblock %}

```

`app/symfony-sample/templates/task/index.html.twig` を修正します。

```twig
{# templates/task/index.html.twig #}

{% extends 'base.html.twig' %}

{% block title %}タスク一覧{% endblock %}

{% block body %}
    <h1>タスク一覧</h1>

    <a href="{{ path('task_new') }}">新しいタスクを作成</a>

    <ul>
        {% for task in tasks %}
            <li>
                {{ task.title }}
            </li>
        {% else %}
            <li>タスクがありません。</li>
        {% endfor %}
    </ul>
{% endblock %}

```

`<a href="{{ path('task_new') }}">新しいタスクを作成</a>` を追加しただけです。

#### 確認

http://localhost:8000/task にアクセスして確認します。

![CleanShot 2024-12-30 at 17.32.00.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9615/e7570ab9-8c9a-9c5b-ce14-0a8086c9df06.gif)

Goooood.

#### Createの作成までをコミット

Createの作成まで確認できたので、ここでいったんコミットしましょう。
```shell
git add app/symfony-sample/src/Controller/TaskController.php app/symfony-sample/templates/task/ app/symfony-sample/src/Form/
git commit -m "feat: Createの作成まで"
```

**作業ログ**

https://github.com/shimabox/devenv/commit/c702ce2ec886435e9366edd5163567163a2527ea

### Update

続いて、CRUDのUを作っていきます。タスクの更新です。

#### Controllerの修正

`task_edit`のルートを作成します。

```php
<?php

namespace App\Controller;

use App\Entity\Task;
use App\Form\TaskType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class TaskController extends AbstractController
{
    // 〜

    #[Route('/task/{id}/edit', name: 'task_edit', methods: ['GET', 'PUT'])]
    public function edit(Task $task, Request $request, EntityManagerInterface $em): Response
    {
        $form = $this->createForm(TaskType::class, $task, [
            'method' => 'PUT',
        ]);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $em->flush();

            return $this->redirectToRoute('task_index');
        }

        return $this->render('task/edit.html.twig', [
            'form' => $form->createView(),
            'task' => $task,
        ]);
    }
}

```

フォームは`src/Form/TaskType.php`を使いまわします。ポイントは`'method' => 'PUT',`をoptionで渡しているところです。後ほど説明します。

#### Viewの作成と修正

`app/symfony-sample/templates/task/edit.html.twig` を作成します。

```twig
{# templates/task/edit.html.twig #}

{% extends 'base.html.twig' %}

{% block title %}タスクを編集{% endblock %}

{% block body %}
    <h1>タスクを編集</h1>

    {{ form_start(form, {
        'method': 'PUT',
        'action': path('task_edit', {'id': task.id})
    }) }}
    {{ form_widget(form) }}
    <button type="submit">更新</button>
    {{ form_end(form) }}

    <a href="{{ path('task_index') }}">戻る</a>
{% endblock %}

```

`app/symfony-sample/templates/task/index.html.twig` を修正します。

```twig
{# templates/task/index.html.twig #}

{% extends 'base.html.twig' %}

{% block title %}タスク一覧{% endblock %}

{% block body %}
    <h1>タスク一覧</h1>

    <a href="{{ path('task_new') }}">新しいタスクを作成</a>

    <ul>
        {% for task in tasks %}
            <li>
                <a href="{{ path('task_edit', {'id': task.id}) }}">{{ task.title }}</a>
            </li>
        {% else %}
            <li>タスクがありません。</li>
        {% endfor %}
    </ul>
{% endblock %}

```

タイトルを、`<a href="{{ path('task_edit', {'id': task.id}) }}">{{ task.title }}</a>`としただけです。

#### PUTを使う

今回、意識高めに更新は`PUT`を使っています。ですが、このまま更新を実行しても何も起こりません。

HTMLフォームの制約として、ブラウザのHTMLフォームはGETとPOSTメソッドのみをサポートしているからです。`method="PUT"`と指定しても、ブラウザはそれをPOSTとして扱います。そのためHTTPメソッドをオーバーライドする必要があり、Symfonyはその術を提供しています。

https://symfony.com/doc/current/forms.html#changing-the-action-and-http-method

以下が必要になります。

- フォーム(`src/Form/TaskType.php`)にメソッドオプションを追加
  - `'method' => 'PUT',` 
- フォーム(`templates/task/edit.html.twig`)で`methodフィールド`を使用
  - `form, {'method': 'PUT',`
- `http_method_override`の有効化
  - 特にこれが大事 

##### http_method_override の有効化

`config/packages/framework.yaml`

```yml
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
    secret: '%env(APP_SECRET)%'

    # Note that the session will be started ONLY if you read or write from it.
    session: true

    #esi: true
    #fragments: true
    
    http_method_override: true

when@test:
    framework:
        test: true
        session:
            storage_factory_id: session.storage.factory.mock_file

```

`http_method_override: true` を追加します。

#### 確認

http://localhost:8000/task にアクセスして確認します。

![CleanShot 2024-12-30 at 18.06.42.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9615/1dc24bca-2b64-6c93-4bf9-0e9b7d4cee42.gif)

Goooood.

#### Updateの作成までをコミット

Updateの作成まで確認できたので、ここでいったんコミットしましょう。
```shell
git add app/symfony-sample/config/packages/framework.yaml app/symfony-sample/src/Controller/TaskController.php app/symfony-sample/templates/task/
git commit -m "feat: Updateの作成まで"
```

**作業ログ**

https://github.com/shimabox/devenv/commit/60ef13421b7183523909c4b4270c44e2d2fcb0da

### Delete

最後に、CRUDのDを作っていきます。タスクの削除です。

#### Controllerの修正

`task_delete`のルートを作成します。

```php
<?php

namespace App\Controller;

use App\Entity\Task;
use App\Form\TaskType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class TaskController extends AbstractController
{
    // 〜

    #[Route('/task/{id}/delete', name: 'task_delete', methods: ['DELETE'])]
    public function delete(Task $task, Request $request, EntityManagerInterface $em): Response
    {
        if ($this->isCsrfTokenValid('delete'.$task->getId(), $request->request->get('_token'))) {
            $em->remove($task);
            $em->flush();
        }

        return $this->redirectToRoute('task_index');
    }
}

```

ポイントは、`$this->isCsrfTokenValid('delete'.$task->getId(), $request->request->get('_token'))` です。フォームを使えばcsrfチェックを勝手にしてくれますが、フォームを使わない場合はこのようにする必要があります。

#### Viewの修正

`app/symfony-sample/templates/task/index.html.twig` を修正します。

```twig
{# templates/task/index.html.twig #}

{% extends 'base.html.twig' %}

{% block title %}タスク一覧{% endblock %}

{% block body %}
    <h1>タスク一覧</h1>

    <a href="{{ path('task_new') }}">新しいタスクを作成</a>

    <ul>
        {% for task in tasks %}
            <li>
                <a href="{{ path('task_edit', {'id': task.id}) }}">{{ task.title }}</a>
                <form action="{{ path('task_delete', {'id': task.id}) }}" method="post" style="display:inline">
                    <input type="hidden" name="_method" value="DELETE">
                    <input type="hidden" name="_token" value="{{ csrf_token('delete' ~ task.id) }}">
                    <button type="submit">削除</button>
                </form>
            </li>
        {% else %}
            <li>タスクがありません。</li>
        {% endfor %}
    </ul>
{% endblock %}

```

`task_delete`のformを追加しただけです。

#### 確認

http://localhost:8000/task にアクセスして確認します。

![CleanShot 2024-12-30 at 18.43.48.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9615/15a37f4d-5b47-6231-3632-0d7f65be261d.gif)

Goooood.

#### Deleteの作成までをコミット

Deleteの作成まで確認できたので、ここでいったんコミットしましょう。
```shell
git add app/symfony-sample/src/Controller/TaskController.php app/symfony-sample/templates/task/index.html.twig
git commit -m "feat: Deleteの作成まで。CRUD、Done！"
```

**作業ログ**

https://github.com/shimabox/devenv/commit/8093e6cd6427ffa09b883fab096c1b896c2eabc0

これで完了です。お疲れ様でした。

## 追記 (2024/12/31)

コメントにて、
> EntityのプロパティをPHPレベルでnon-nullableにするのは（make:entityのデフォルトがnullableになっていることからも）最近のSymfonyコミュニティの慣習から外れていそうなので、あえてそうしなくてもいいかなと思いました。

というのを頂きました。なるほどー、`make:entity`で作成されたデフォルトのEntityを修正するのは違和感があったのですよね。EntityはEntityなので、それをどう扱うかはドメイン側の仕事なのかなと思いました。

というわけで修正しています。

https://github.com/shimabox/devenv/commit/232c464ce43b8ecf908909c6b36c5eeda6b324e3

## まとめ

- カオナビ本の、Chapter05 モダンなPHPフレームワーク(05-04 Symfony) のサンプルコードをチュートリアル形式で実行してみました
- エンティティを作成してマイグレーションファイルの生成、適用を行いました
  - 環境変数で`DATABASE_HOST`を分けて実行したりもしました
  - 他にいいやり方があるかもしれません(誰か教えてください)
- 簡単なCRUDを作成して、`http_method_override: true`の注意点を知ることができました

## おわりに

この年末年始は、カオナビ本を片手にお勉強をしてみるのはいかがでしょうか？

https://www.amazon.co.jp/dp/4798073229

ちなみに、まだ:fire:火力(レビュー):fire:が足りません！辛辣なレビューでもいいので、ぜひお願いします！

それではみなさんよいお年を！