2026 暑訓 / COURSE 02.06

Day 4 - CMS 教學 2

從 Bake 與模型關聯出發,為 CakePHP CMS 加入 Users、Tags、文章標籤篩選與更完整的 ORM 實作。

INSTRUCTOR王朝威UPDATED2026.08.23

完成基本的 Articles CRUD 後,接下來要讓 CMS 開始具備更完整的內容管理能力。

這一天主要會完成兩件事:

  • 使用 Bake 建立 Users 與 Tags 的基本功能
  • 建立 Articles 與 Tags 的多對多關聯,並實作依標籤篩選文章

最後,我們還會改善標籤輸入方式,讓使用者不必事先建立所有標籤,而是可以直接輸入:

CakePHP, PHP, ORM

系統再自動建立或連結對應的 Tags。


1. 調整文章編輯後的重新導向

首先稍微修改上一章的 edit()。

原本文章更新成功後會回到文章列表,現在改成直接回到文章詳細頁:

public function edit(?string $slug = null)
{
    $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(
                __('您的文章已經更新。')
            );

            return $this->redirect([
                'action' => 'view',
                $article->slug
            ]);
        }

        $this->Flash->error(
            __('無法更新您的文章。')
        );
    }

    $this->set('article', $article);
}

更新文章後會直接回到:

/articles/view/{slug}

讓使用者可以立即查看修改結果。


2. 使用 Bake 建立 Users

前面的 Articles 功能主要是手動建立:

Model
Controller
Template

這次我們改用 CakePHP 的 Bake Console 自動產生骨架程式碼。

進入 CakePHP 專案:

cd /CMS

執行:

bin/cake bake model users
bin/cake bake controller users
bin/cake bake template users

Bake 會依照 CakePHP Convention,自動分析 Database Schema 並產生需要的程式碼。


Bake 會產生什麼?

上面的指令主要會產生:

Model
→ UsersTable
→ User Entity

Controller
→ UsersController

Templates
→ index
→ view
→ add
→ edit

Tests
→ 對應的測試檔案

其中 Controller 與 Templates 會包含基本 CRUD 功能。

也就是:

Create
Read
Update
Delete

Bake 的價值並不是「完全不用寫程式」,而是先幫我們建立符合 CakePHP Convention 的 Application Skeleton,再針對實際需求修改。


3. 使用 Bake 建立 Tags

接下來加入 Tags 功能。

這次使用:

bin/cake bake all tags

all 會一次產生 Tags 相關的:

Model
Controller
Templates
Tests

例如:

TagsTable
Tag Entity
TagsController
templates/Tags/*

完成後可以前往:

http://localhost:8080/tags/add

新增幾個測試標籤。


4. Articles 與 Tags 的關聯

一篇 Article 可以有很多 Tag,而同一個 Tag 也可以被很多 Article 使用。

因此它們之間屬於:

BelongsToMany

也就是多對多關係。

在:

src/Model/Table/ArticlesTable.php

的 initialize() 加入:

public function initialize(array $config): void
{
    $this->addBehavior('Timestamp');
    $this->belongsToMany('Tags');
}

CakePHP 會依照 Convention 尋找:

articles_tags

作為聯結資料表(Join Table)。

其中通常包含:

article_id
tag_id

形成:

Articles
    ↕
articles_tags
    ↕
Tags

這也再次展示 CakePHP 的:

Convention over Configuration

只要 Table 與欄位名稱符合 Convention,就不需要額外指定大量 Association Configuration。


在新增文章時選擇 Tags

建立 Association 後,就可以讓使用者在新增文章時選擇 Tag。

先修改:

src/Controller/ArticlesController.php

在 add() 中取得 Tags 清單:

$tags = $this->Articles->Tags
    ->find('list')
    ->all();

$this->set('tags', $tags);
$this->set('article', $article);

這裡:

find('list')

會產生適合表單使用的清單資料。

概念上類似:

1 => CakePHP
2 => PHP
3 => Docker

接著把 $tags 傳到 Template。


在 Form 中加入 Tag 選擇

修改:

templates/Articles/add.php

加入:

echo $this->Form->control(
    'tags._ids',
    ['label' => '標籤', 'options' => $tags]
);

完整概念:

echo $this->Form->create($article);

echo $this->Form->control(
    'user_id',
    ['type' => 'hidden', 'value' => 1]
);

echo $this->Form->control(
    'title',
    ['label' => '標題']
);

echo $this->Form->control(
    'tags._ids',
    ['label' => '標籤', 'options' => $tags]
);

echo $this->Form->control(
    'body',
    ['label' => '內文', 'rows' => '3']
);

echo $this->Form->button(
    __('儲存文章')
);

echo $this->Form->end();

tags._ids 是 CakePHP 用來處理多對多 Association 的常見資料結構。

使用者選擇 Tag 後,CakePHP ORM 可以幫我們同步更新 articles_tags 的關聯資料。


編輯文章時載入 Tags

編輯 Article 時也要一併載入現有的 Tag。

修改 edit():

$article = $this->Articles
    ->findBySlug($slug)
    ->contain('Tags')
    ->firstOrFail();

其中:

contain('Tags')

表示:

查詢 Article 時,也一起載入與這篇 Article 關聯的 Tags。

另外同樣取得 Tag List:

$tags = $this->Articles->Tags
    ->find('list')
    ->all();

$this->set('tags', $tags);
$this->set('article', $article);

然後在:

templates/Articles/edit.php

加入:

echo $this->Form->control(
    'tags._ids',
    ['label' => '標籤', 'options' => $tags]
);

現在新增與編輯文章時,都能選擇一個或多個 Tags。


5. 依標籤篩選文章

接下來加入依 Tag 篩選 Article 的功能。

我們希望可以使用:

/articles/tagged/funny

或:

/articles/tagged/funny/cat/gifs

查詢具有指定標籤的文章。

例如:

/articles/tagged/cakephp

可以顯示 CakePHP 相關文章。


建立 Tag Route

修改:

config/routes.php

加入:

$builder->scope('/articles', function (RouteBuilder $builder) {
        $builder->connect(
            '/tagged/*',
            ['controller' => 'Articles', 'action' => 'tags']
        );
});

這個 Route 將:

/articles/tagged/*

對應到:

ArticlesController::tags()

其中:

*

代表後面可以接受多個 Path Parameter。

例如:

/articles/tagged/funny/cat

就包含:

funny
cat

兩個參數。


建立 tags() Action

在 ArticlesController 中加入:

public function tags()
{
    $tags = $this->request->getParam('pass');

    $articles = $this->Articles
        ->find('tagged', tags: $tags)
        ->all();

    $this->set([
        'articles' => $articles,
        'tags' => $tags
    ]);
}

這裡:

$this->request->getParam('pass')

取得 Route 中傳入的 Path Parameters。

例如:

/articles/tagged/funny/cat

可能取得:

[
    'funny',
    'cat'
]

Variadic Argument 寫法

也可以直接利用 PHP 的:

Variadic Argument

把 tags() 改寫成:

public function tags(...$tags)
{
    $articles = $this->Articles
        ->find('tagged', tags: $tags)
        ->all();

    $this->set([
        'articles' => $articles,
        'tags' => $tags
    ]);
}

其中:

...$tags

會把多個 Route Parameter 收集成 Array。

例如:

funny
cat
gifs

會變成:

[
    'funny',
    'cat',
    'gifs'
]

建立自訂 Finder

Controller 中用了:

$this->Articles->find(
    'tagged',
    tags: $tags
)

但目前:

findTagged()

還不存在。

CakePHP 鼓勵 Controller 保持簡潔,因此真正的查詢邏輯可以放到 Model。

在:

src/Model/Table/ArticlesTable.php

加入:

use Cake\ORM\Query\SelectQuery;

然後建立:

public function findTagged(
    SelectQuery $query,
    array $tags = []
): SelectQuery {

    $columns = [
        'Articles.id',
        'Articles.user_id',
        'Articles.title',
        'Articles.body',
        'Articles.published',
        'Articles.created',
        'Articles.slug',
    ];

    $query = $query
        ->select($columns)
        ->distinct($columns);

    if (empty($tags)) {

        $query->leftJoinWith('Tags')
            ->where([
                'Tags.title IS' => null
            ]);

    } else {

        $query->innerJoinWith('Tags')
            ->where([
                'Tags.title IN' => $tags
            ]);
    }

    return $query->groupBy([
        'Articles.id'
    ]);
}

這就是 CakePHP 的:

Custom Finder


findTagged() 在做什麼?

首先:

SelectQuery $query

是 CakePHP ORM 的 Query Builder。

而:

array $tags

則是從 Controller 傳進來的 Tag List。


沒有指定 Tag

如果:

empty($tags)

就使用:

leftJoinWith('Tags')

並限制:

'Tags.title IS' => null

因此會找出:

沒有任何 Tag 的文章。


有指定 Tag

如果有:

[
    'funny',
    'cat'
]

則使用:

innerJoinWith('Tags')

搭配:

'Tags.title IN' => $tags

找出具有指定 Tag 的文章。

這裡的語意是:

符合其中任一 Tag 即可。

並不是要求 Article 同時具有全部 Tags。


distinct() 與 groupBy()

Article 與 Tag Join 後,同一篇文章可能因為符合多個 Tag 而出現多次。

因此 Query 使用:

distinct($columns)

與:

groupBy(['Articles.id'])

避免相同 Article 重複出現在結果中。


建立 tags View Template

建立:

templates/Articles/tags.php
<h1>
    標籤為
    <?= $this->Text->toList(h($tags), '或') ?>
    的文章
</h1>

<section>

<?php foreach ($articles as $article): ?>

    <article>

        <h4>
            <?= $this->Html->link(
                $article->title,
                [
                    'controller' => 'Articles',
                    'action' => 'view',
                    $article->slug
                ]
            ) ?>
        </h4>

        <span>
            <?= h($article->created) ?>
        </span>

    </article>

<?php endforeach; ?>

</section>

這裡使用兩個 Helper。


HtmlHelper 與 TextHelper

HtmlHelper:

$this->Html->link()

負責產生 HTML Link。

TextHelper:

$this->Text->toList()

可以把 Array 轉成比較容易閱讀的文字。

例如:

[
    'funny',
    'cat',
    'gifs'
]

可以整理成:

funny、cat 或 gifs

使用 h() 處理輸出

Template 中:

h($tags)

以及:

h($article->created)

都會先做 HTML Encoding。

在顯示使用者可控制的內容時,應避免直接把原始資料輸出到 HTML。

這可以降低 HTML Injection 與 XSS 相關風險。


測試 Tag Filtering

現在可以開啟:

/articles/tagged/funny

如果 Article 有:

funny

Tag,就會顯示在結果中。

也可以:

/articles/tagged/funny/cat/gifs

查詢符合其中任一 Tag 的 Article。


6. 改善 Tags 輸入方式

目前的 Tags Form 使用多選欄位。

這代表作者需要先到:

/tags/add

建立 Tag,才能回到 Article Form 中選擇。

使用流程比較繁瑣。

我們希望改成一個普通文字欄位。

例如:

CakePHP, PHP, Docker

讓使用者直接輸入 Tag。

如果 Tag 已經存在,就建立 Association。

如果不存在,就自動建立新的 Tag。


建立 tag_string 計算屬性

在:

src/Model/Entity/Article.php

加入:

use Cake\Collection\Collection;

然後將:

'tag_string' => true

加入 $_accessible。

例如:

protected array $_accessible = [
    // 其他欄位...
    'tag_string' => true,
];

接著建立 Getter:

protected function _getTagString(): string
{
    if (isset($this->_fields['tag_string'])) {
        return $this->_fields['tag_string'];
    }

    if (empty($this->tags)) {
        return '';
    }

    $tags = new Collection($this->tags);

    $str = $tags->reduce(
        function ($string, $tag) {
            return $string
                . $tag->title
                . ', ';
        },
        ''
    );

    return trim($str, ', ');
}

這是一個 Entity 的:

Computed Property

並不是 Database 中真的多出:

tag_string

欄位。

之後可以直接使用:

$article->tag_string

得到:

CakePHP, PHP, Docker

修改 Article Form

原本:

$this->Form->control(
    'tags._ids',
    ['options' => $tags]
);

改成:

echo $this->Form->control(
    'tag_string',
    ['label' => '標籤', 'type' => 'text']
);

需要同時修改:

templates/Articles/add.php
templates/Articles/edit.php

使用者現在只需要輸入:

CakePHP, PHP, ORM

就可以設定 Tags。


在 Article 詳細頁顯示 Tags

修改:

templates/Articles/view.php

加入:

<p>
    <b>標籤:</b>
    <?= h($article->tag_string) ?>
</p>

這裡使用剛剛建立的:

$article->tag_string

Computed Property。


載入 Tags Association

要計算:

$article->tag_string

Article Entity 必須先有:

$article->tags

資料。

因此 view() 要修改成:

public function view($slug = null)
{
    $article = $this->Articles
        ->findBySlug($slug)
        ->contain('Tags')
        ->firstOrFail();

    $this->set(compact('article'));
}

contain('Tags') 告訴 ORM:

查詢 Article 時,一併載入 Tags Association。


儲存 tag_string

現在 Form 已經可以傳入:

tag_string

接下來需要在 Article 儲存前把它轉換成真正的 Tags。

在:

ArticlesTable.php

的 beforeSave() 加入:

public function beforeSave(
    EventInterface $event,
    $entity,
    $options
): void {

    if ($entity->tag_string) {
        $entity->tags = $this->_buildTags(
            $entity->tag_string
        );
    }

    // 其他 beforeSave 邏輯...
}

這表示每次儲存 Article 前:

tag_string
↓
解析
↓
Tags Entity
↓
Article.tags

建立 _buildTags()

接著加入:

protected function _buildTags($tagString): array {

    $newTags = array_map('trim', explode(',', $tagString));
    $newTags = array_filter($newTags);
    $newTags = array_unique($newTags);

    $out = [];

    $tags = $this->Tags
        ->find()
        ->where(['Tags.title IN' => $newTags])
        ->all();

    foreach ($tags->extract('title') as $existing) {

        $index = array_search($existing, $newTags);

        if ($index !== false) {
            unset($newTags[$index]);
        }
    }

    foreach ($tags as $tag) {
        $out[] = $tag;
    }

    foreach ($newTags as $tag) {
        $out[] = $this->Tags
            ->newEntity(['title' => $tag]);
    }

    return $out;
}

_buildTags() 的流程

假設使用者輸入:

CakePHP, PHP, Docker, CakePHP

首先:

explode(',', $tagString)

拆成:

[
    'CakePHP',
    ' PHP',
    ' Docker',
    ' CakePHP'
]

接著:

array_map('trim', ...)

去掉空白:

[
    'CakePHP',
    'PHP',
    'Docker',
    'CakePHP'
]

再使用:

array_unique()

去除重複:

[
    'CakePHP',
    'PHP',
    'Docker'
]

然後查詢 Database 中已存在的 Tag:

$this->Tags
    ->find()
    ->where(['Tags.title IN' => $newTags])

已存在的 Tag 直接使用原本的 Entity。

不存在的則:

$this->Tags->newEntity(...)

建立新 Tag。

因此最後 ORM 可以同時處理:

Existing Tags
+
New Tags
+
Articles ↔ Tags Association

ORM 在這裡做了什麼?

ORM 全名是:Object-Relational Mapping

中文通常翻譯成:物件關聯對映

在 CakePHP 中,我們透過:

Table
Entity
Query
Association

等 PHP Object 操作 Database。

例如:

$this->Tags->find()

查詢資料。

$this->Tags->newEntity()

建立新的 Entity。

再透過:

$this->Articles->save($article)

讓 ORM 負責將 Article、Tags 與 Join Table 中的關聯資料儲存到 Database。


設定 Join Table

最後調整:

$this->belongsToMany('Tags')

成:

$this->belongsToMany(
    'Tags',
    [
        'joinTable' => 'articles_tags',
        'dependent' => true
    ]
);

完整:

public function initialize(array $config): void {

    $this->addBehavior('Timestamp');

    $this->belongsToMany(
        'Tags',
        [
            'joinTable' => 'articles_tags',
            'dependent' => true
        ]
    );
}

其中:

'joinTable' => 'articles_tags'

明確指定 Join Table。

而:

'dependent' => true

代表刪除 Article 時,CakePHP 會一併清除:

articles_tags

中與該 Article 相關的 Association Record。


7. edit() 也要 contain Tags

最後別忘了 Edit Page。

修改:

public function edit($slug)
{
    $article = $this->Articles
        ->findBySlug($slug)
        ->contain('Tags')
        ->firstOrFail();

    // ...
}

如此一來:

view()
edit()

都會在載入 Article 時一併取得 Tags。

當:

$article->tag_string

被呼叫時,就能根據:

$article->tags

產生正確的 Tag String。


8. Day 4 重點整理

這一天首先利用:

Bake

快速建立:

Users
Tags

的 CRUD 骨架。

接著建立:

Articles
↕ BelongsToMany
Tags

Association。

並實作:

Tag List
↓
Article Form
↓
ORM Association

接著建立:

/articles/tagged/*

Route,再透過:

tags()
↓
find('tagged')
↓
findTagged()

完成依標籤篩選 Article。

最後將原本的多選欄位改成:

CakePHP, PHP, Docker

形式的文字輸入。

完整流程可以整理成:

tag_string
↓
beforeSave()
↓
_buildTags()
↓
尋找 Existing Tag
+
建立 New Tag
↓
Article.tags
↓
CakePHP ORM
↓
articles
tags
articles_tags

這也是本章最重要的觀念:

CakePHP ORM 不只用來執行資料庫查詢,也可以透過 Entity、Association 與 Lifecycle Callback,把應用程式中的資料處理邏輯組織成更容易維護的結構。


下一步

完成 Users 與 Tags 的基本功能後,我們的 CMS 已經可以:

  • 管理文章
  • 管理使用者
  • 管理標籤
  • 建立文章與標籤關聯
  • 依標籤篩選文章
  • 自動建立新標籤

下一階段會開始處理真正的使用者身份:

Authentication

也就是:

密碼 Hash
登入
登出
Session
Authentication Middleware

讓 CMS 從「可以管理多個 Users」進一步變成:

真正能讓不同使用者登入使用的 CMS。

COURSE FILES

附件下載

FILE2026 CakePHP-Day4 CakePHP CMS 教學2.pdf下載 ↓
NEXT COURSE / 02.07Day 5 - CMS 教學 3 →