CakePHPでCRUDアプリケーション

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

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

環境

  • Windows 10 Home 22H2
  • WSL2 Ubuntu 22.04.2 LTS
  • Docker Desktop 4.19.0
  • CakePHP 4.4.13

データベース作成

参考: CMS チュートリアル – データベース作成

Dockerのdbコンテナに入りテーブルを作成します。

~/cakephp
yoyoyo@wsl:~/cakephp$ docker exec -it cakephp-db-1 bash

docker-composeで記述したcakephptestが作成されています。

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しておきます。

データベース作成
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をインストールした直後のフォルダ構成はこのようになっています。

srcにアプリケーションに必要となるプログラムを書いていきます。

モデルの作成

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

Tableの作成

ひとまずチュートリアルにしたがってsrc/Model/Table/ArticlesTable.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');createdmodifiedカラムが自動的に更新されます。
DATETIME型のカラムが対象なのかな。

Entityの作成

また、Entityクラスの作成も行います。

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といった感じでしょうか。

リストページの作成

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

コントローラー

参考: CMS チュートリアル – Articles コントローラーの作成

MVCのCです。CakePHPへのHTTPリクエストを処理するビジネスロジックを記述します。

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/は…?

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テーブルのtitleslugからリンク(HTMLのa要素)を生成している模様。

この時点で/articles/indexにアクセスすると、記事のリストが表示されるようになりました!

しかし、記事のリンクをクリックしても、対応するアクションが実装されていないためエラーになってしまいました。

先ほどのindex.phpの$this->Html->link($article->title, ['action' => 'view', $article->slug])viewアクションを指定していましたね。

ビューページの作成

それぞれの記事ごとのページです。

コントローラー

src/Controller/ArticlesController.phpview()メソッドを追加します。

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を追加します。

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>

ビューページも完成しました。

記事の追加ページ

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

コントローラー

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 失敗したかをユーザーに知らせます。

ビュー

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でクラスを定義しているので、ここは異なる点。

リストページにも記事の追加へのリンクを追加しておきます。

templates/Articles/index.php
<?= $this->Html->link('記事の追加', ['action' => 'add']) ?>

ところが追加に失敗してしまいます。

article->slugはNUT NULLでした。

モデルでNUT NULLチェック

src/Model/Table/ArticlesTable.phpbeforeSave()メソッドを追加します。

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の値を登録するようにしています。

これで、記事の追加ができるようになりました。

Flashコンポーネントの表示もされています。

記事の編集ページ

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

コントローラー

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アクションと同じですね。

ビュー

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();
?>

リストページにも編集ページへのリンクを追加。

templates/Articles/index.php
<td>
    <?= $this->Html->link('編集', ['action' => 'edit', $article->slug]) ?>
</td>

バリデーションの追加

Phalconではformsに記載していますね

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の対応が分かってきました。

記事の削除ページ

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

コントローラー

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です。

ビュー

リストページに削除ページへのリンクを追加するだけ。

templates/Articles/index.php
<?= $this->Form->postLink(
    '削除',
    ['action' => 'delete', $article->slug],
    ['confirm' => 'よろしいですか?'])
?>

ここではpostLink()を使ってJavaScriptによる確認ダイアログを表示させています。

記事の削除もできました。

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

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

参考記事

CakePHP3でWebアプリ開発

本記事では、CakePHPを使って、顧客管理システムを作ることを目的として、システムへの認証機能から順を追って説明していきます。

CakePHP4 で bakeable な開発環境の実現 [Model編]

CakePHP3で画像投稿機能付き掲示板作成 ~第一回 CRUDの実装~

CakePHP 3.0 新機能Cellを試してみた

Cakephp3 + TCPDF でpdf作成

CakePHP3でtwitter bootstrapを使う(導入編)

Composerを使ってみよう(CakePHP, debug_kit)