CakePHP4のチュートリアルを試す① ～CRUD操作～

## はじめに
こちらの記事で作成したDocker上のCakePHPで、[公式サイトのチュートリアル](https://book.cakephp.org/4/ja/tutorials-and-examples.html)をなぞってみます。
筆者はPHPのフレームワーク経験はPhalconのみ、というレアな人間です。

https://qiita.com/yoyoyo4/items/8a778eb7e352965ec77b

しかしCakePHP4のチュートリアル記事はあまり見かけませんね…
やはり時代はLaravelなのでしょうか。

この記事では、モデルのコントローラーを通したCRUD操作、ビューへの反映までを行います。

## 環境
- Windows 10 Home 22H2
- WSL2 Ubuntu 22.04.2 LTS
- Docker Desktop 4.19.0
- CakePHP 4.4.13

## データベース作成
https://book.cakephp.org/4/ja/tutorials-and-examples/cms/database.html

Dockerのdbコンテナに入りテーブルを作成します。
```bash:~/cakephp
yoyoyo@wsl:~/cakephp$ docker exec -it cakephp-db-1 bash
```
docker-composeで記述した`cakephptest`が作成されています。
```bash:cakephp-db-1
bash-4.4# mysql -u cake -p
mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| cakephptest        |
| information_schema |
| performance_schema |
+--------------------+
3 rows in set (0.09 sec)
mysql> use cakephptest
Database changed
```
チュートリアルどおりに`users`テーブル、`articles`テーブル、`tags`テーブル、`articles_tags`テーブルを作成し、初期値をINSERTしておきます。
```sql:データベース作成
CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) NOT NULL,
    password VARCHAR(255) NOT NULL,
    created DATETIME,
    modified DATETIME
);

CREATE TABLE articles (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    title VARCHAR(255) NOT NULL,
    slug VARCHAR(191) NOT NULL,
    body TEXT,
    published BOOLEAN DEFAULT FALSE,
    created DATETIME,
    modified DATETIME,
    UNIQUE KEY (slug),
    FOREIGN KEY user_key (user_id) REFERENCES users(id)
) CHARSET=utf8mb4;

CREATE TABLE tags (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(191),
    created DATETIME,
    modified DATETIME,
    UNIQUE KEY (title)
) CHARSET=utf8mb4;

CREATE TABLE articles_tags (
    article_id INT NOT NULL,
    tag_id INT NOT NULL,
    PRIMARY KEY (article_id, tag_id),
    FOREIGN KEY tag_key(tag_id) REFERENCES tags(id),
    FOREIGN KEY article_key(article_id) REFERENCES articles(id)
);

INSERT INTO users (email, password, created, modified)
VALUES
('cakephp@example.com', 'secret', NOW(), NOW());

INSERT INTO articles (user_id, title, slug, body, published, created, modified)
VALUES
(1, 'First Post', 'first-post', 'This is the first post.', 1, now(), now());
```
Dockerを使っていても、データベースをいじるには直接コンテナに入ってSQLを実行するというのはなんだかアナログな気がします。（もっとスマートな方法があったら教えてください。）

## CakePHPのフォルダ構成
CakePHPをインストールした直後のフォルダ構成はこのようになっています。
![CakePHP html](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348786/aeb38adf-8623-1ce8-6fd6-b3f7ff149617.jpeg)
`src`にアプリケーションに必要となるプログラムを書いていきます。
![CakePHP src](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348786/d3b05051-78f9-6ec9-6778-5b4f1b11a48f.jpeg)

## モデルの作成
モデルはデータの読み書きを行います。MVCのMですね。
Phalconでいう`models`のようですが、`Behavior`と`Entity`と`Table`がありなんだか複雑そうです。

### Tableの作成
ひとまずチュートリアルにしたがって`src/Model/Table/ArticlesTable.php`を作成しました。
```php:src/Model/Table/ArticlesTable.php
<?php
// src/Model/Table/ArticlesTable.php
namespace App\Model\Table;

use Cake\ORM\Table;

class ArticlesTable extends Table
{
    public function initialize(array $config) : void
    {
        $this->addBehavior('Timestamp');
    }
}
```
Tableクラスの名前を複数形+パスカルケース+`Table`にすることで、データベース上のテーブル名と紐づけられるようです。
主キーは`id`であると解釈されます。これは設計がややこしくなりそう…

`$this->addBehavior('Timestamp');`で`created`と`modified`カラムが自動的に更新されます。
DATETIME型のカラムが対象なのかな。

### Entityの作成 
また、`Entity`クラスの作成も行います。
```php:src/Model/Entity/Article.php
<?php
// src/Model/Entity/Article.php
namespace App\Model\Entity;

use Cake\ORM\Entity;

class Article extends Entity
{
    protected $_accessible = [
        '*' => true,
        'id' => false,
        'slug' => false,
    ];
}
```
`_accessible`プロパティでカラムへの操作の権限を制御するようです。
テーブル全体への操作が`Table`、レコードごとの操作が`Entity`といった感じでしょうか。

## リストページの作成
ここからは機能ごとにコントローラーとビューを作成していきます。
まずはモデルから記事一覧を取得して表示するページです。

### コントローラー
https://book.cakephp.org/4/ja/tutorials-and-examples/cms/articles-controller.html

MVCのCです。CakePHPへのHTTPリクエストを処理するビジネスロジックを記述します。
```php:src/Controller/ArticlesController.php
<?php
// src/Controller/ArticlesController.php

namespace App\Controller;

class ArticlesController extends AppController
{
    public function index()
    {
        $this->loadComponent('Paginator');
        $articles = $this->Paginator->paginate($this->Articles->find());
        $this->set(compact('articles'));
    }
}
```
/articles/indexへのアクセスが`index()`メソッドで処理されます。
`paginate()`でページネーションされた記事を取得し、`set()`でビューに渡しています。

### ビュー
MVCのV。CakePHPのビューは素のPHPファイルで記述するようです。
Phalconでいう`Volt`にあたるテンプレートエンジンはなさそう。

場所は`templates`以下。あれ…？`src/View/`は…？
```php:templates/Articles/index.php
<!-- File: templates/Articles/index.php -->

<h1>記事一覧</h1>
<table>
    <tr>
        <th>タイトル</th>
        <th>作成日時</th>
    </tr>

    <!-- ここで、$articles クエリーオブジェクトを繰り返して、記事の情報を出力します -->

    <?php foreach ($articles as $article): ?>
    <tr>
        <td>
            <?= $this->Html->link($article->title, ['action' => 'view', $article->slug]) ?>
        </td>
        <td>
            <?= $article->created->format(DATE_RFC850) ?>
        </td>
    </tr>
    <?php endforeach; ?>
</table>
```
`ArticlesController`から渡された`$articles`変数が使えます。
`$this->Html`オブジェクトの`link()`メソッドで、`article`テーブルの`title`と`slug`からリンク（HTMLのa要素）を生成している模様。

この時点で/articles/indexにアクセスすると、記事のリストが表示されるようになりました！
![/articles/index](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348786/86dde958-0a96-a759-691e-e6a147b90c31.jpeg)
しかし、記事のリンクをクリックしても、対応するアクションが実装されていないためエラーになってしまいました。
![Missing Method in ArticlesController](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348786/975dada3-84ea-a6d8-ce65-1d273dc69b54.png)
先ほどのindex.phpの`$this->Html->link($article->title, ['action' => 'view', $article->slug])`で`view`アクションを指定していましたね。

## ビューページの作成
それぞれの記事ごとのページです。

### コントローラー
`src/Controller/ArticlesController.php`に`view()`メソッドを追加します。
```php:src/Controller/ArticlesController.php
public function view($slug = null)
{
    $article = $this->Articles->findBySlug($slug)->firstOrFail();
    $this->set(compact('article'));
}
```
`findBySlug()`でクエリーを書いているらしい。UNIQUEじゃなかった場合はどうなるのでしょうか。
`firstOrFail()`についても、どうやって最初と判断しているのか不思議。ORDER BY？

`$slug`はCakePHPのルーティングとディスパッチャーから取得されます。
確かにリストページのURLも/articles/view/first-postとなっていました。

### ビュー
`templates/Articles/view.php`を追加します。
```php:templates/Articles/view.php
<!-- File: templates/Articles/view.php -->

<h1><?= h($article->title) ?></h1>
<p><?= h($article->body) ?></p>
<p><small>作成日時: <?= $article->created->format(DATE_RFC850) ?></small></p>
<p><?= $this->Html->link('Edit', ['action' => 'edit', $article->slug]) ?></p>
```
ビューページも完成しました。
![/articles/view](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348786/bef38a1e-11a5-86ab-18b6-fec298d6f323.jpeg)

## 記事の追加ページ
新しい記事を作成するページを作ります。

### コントローラー
```php:src/Controller/ArticlesController.php
    public function add()
    {
        $article = $this->Articles->newEmptyEntity();
        if ($this->request->is('post')) {
            $article = $this->Articles->patchEntity($article, $this->request->getData());

            // user_id の決め打ちは一時的なもので、あとで認証を構築する際に削除されます。
            $article->user_id = 1;

            if ($this->Articles->save($article)) {
                $this->Flash->success(__('Your article has been saved.'));
                return $this->redirect(['action' => 'index']);
            }
            $this->Flash->error(__('Unable to add your article.'));
        }
        $this->set('article', $article);
    }
```
データがPOSTされた場合、`$this->request->getData()`でデータを取得して、`save()`で保存します。

`Flash`コンポーネントで、レイアウトの中にフラッシュメッセージを表示し、記事の追加に成功したか or 失敗したかをユーザーに知らせます。

### ビュー
```php:templates/Articles/add.php
<!-- File: templates/Articles/add.php -->

<h1>記事の追加</h1>
<?php
    echo $this->Form->create($article);
    // 今はユーザーを直接記述
    echo $this->Form->control('user_id', ['type' => 'hidden', 'value' => 1]);
    echo $this->Form->control('title');
    echo $this->Form->control('body', ['rows' => '3']);
    echo $this->Form->button(__('Save Article'));
    echo $this->Form->end();
?>
```
`$this->Form->create()`はいわゆるFormHelperですね。
Phalconはformsでクラスを定義しているので、ここは異なる点。

リストページにも記事の追加へのリンクを追加しておきます。
```php:templates/Articles/index.php
<?= $this->Html->link('記事の追加', ['action' => 'add']) ?>
```
記事の追加ページもできました。
![/articles/add](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348786/ff653aae-d5b0-ed77-b963-15a31716c9c2.jpeg)
ところが追加に失敗してしまいます。
![/articles/add error](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348786/d3330621-5d4e-52aa-0f14-3756a46d8c25.png)
article->slugはNUT NULLでした。

### モデルでNUT NULLチェック
`src/Model/Table/ArticlesTable.php`に`beforeSave()`メソッドを追加します。
```php:src/Model/Table/ArticlesTable.php
    public function beforeSave(EventInterface $event, $entity, $options)
    {
        if ($entity->isNew() && !$entity->slug) {
            $sluggedTitle = Text::slug($entity->title);
            // スラグをスキーマで定義されている最大長に調整
            $entity->slug = substr($sluggedTitle, 0, 191);
        }
    }
```
`slug`が空であった場合、`title`の値を登録するようにしています。

これで、記事の追加ができるようになりました。
![/articles/add success](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348786/ccf8c34b-71d3-5905-633c-98d68a21ac95.jpeg)
`Flash`コンポーネントの表示もされています。

## 記事の編集ページ
記事を編集するページを作ります。

### コントローラー
```php:src/Controller/ArticlesController.php
public function edit($slug)
{
    $article = $this->Articles->findBySlug($slug)->firstOrFail();
    if ($this->request->is(['post', 'put'])) {
        $this->Articles->patchEntity($article, $this->request->getData());
        if ($this->Articles->save($article)) {
            $this->Flash->success(__('Your article has been updated.'));
            return $this->redirect(['action' => 'index']);
        }
        $this->Flash->error(__('Unable to update your article.'));
    }

    $this->set('article', $article);
}
```
`view`アクションと同じく、`$slug`から`findBySlug()`と`firstOrFail()`で記事を取得します。
編集したデータのモデルへの反映は`add`アクションと同じですね。

### ビュー
```php:templates/Articles/edit.php
<!-- File: templates/Articles/edit.php -->

<h1>記事の編集</h1>
<?php
    echo $this->Form->create($article);
    echo $this->Form->control('user_id', ['type' => 'hidden']);
    echo $this->Form->control('title');
    echo $this->Form->control('body', ['rows' => '3']);
    echo $this->Form->button(__('Save Article'));
    echo $this->Form->end();
?>
```
リストページにも編集ページへのリンクを追加。
```php:templates/Articles/index.php
<td>
    <?= $this->Html->link('編集', ['action' => 'edit', $article->slug]) ?>
</td>
```
編集も完了です。
![/articles/edit success](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348786/da5ee985-6e6f-06a6-19a1-bdb760d2df63.png)

### バリデーションの追加
Phalconでは`forms`に記載していますね
```php:src/Model/Table/ArticlesTable.php。
public function validationDefault(Validator $validator): Validator
{
    $validator
        ->notEmptyString('title')
        ->minLength('title', 10)
        ->maxLength('title', 255)

        ->notEmptyString('body')
        ->minLength('body', 10);

    return $validator;
}
```
`validationDefault()`は`save()`メソッドが呼ばれる際にデータのバリデーションを行います。
だんだんPhalconとCakePHPの対応が分かってきました。

## 記事の削除ページ
最後に、記事を削除するページを作ります。

### コントローラー
```php:src/Controller/ArticlesController.php
public function delete($slug)
{
    $this->request->allowMethod(['post', 'delete']);

    $article = $this->Articles->findBySlug($slug)->firstOrFail();
    if ($this->Articles->delete($article)) {
        $this->Flash->success(__('The {0} article has been deleted.', $article->title));
        return $this->redirect(['action' => 'index']);
    }
}
```
`$slug`から`findBySlug()`と`firstOrFail()`で記事を取得し、`delete()`で削除。
シンプルでわかりやすいCRUDです。

### ビュー
リストページに削除ページへのリンクを追加するだけ。
```php:templates/Articles/index.php
<?= $this->Form->postLink(
    '削除',
    ['action' => 'delete', $article->slug],
    ['confirm' => 'よろしいですか?'])
?>
```
ここでは`postLink()`を使ってJavaScriptによる確認ダイアログを表示させています。
![/articles/delete confirm](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348786/ac97a29f-5710-cb31-f9a3-a199b1f84c8b.jpeg)

記事の削除もできました。
![/articles/delete success](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3348786/2e17cb1a-08f1-e21e-b279-0569c65eecde.png)

## おわりに
各プログラムの役割が規定のファイルに厳密に対応づけられており、「設定より規約」の思想が強く感じられました。
とはいうものの、MVCモデルに慣れた方はすぐ理解できたのではないでしょうか。

Phalconは割と自由にいじれる分、初心者には入りづらい部分もあるのですが、CakePHPはフレームワークに従って実装をするだけで、わかりやすく開発ができそうです。