2026 暑訓 / COURSE 02.06
Day 4 - CMS 教學 2
從 Bake 與模型關聯出發,為 CakePHP CMS 加入 Users、Tags、文章標籤篩選與更完整的 ORM 實作。
完成基本的 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 usersBake 會依照 CakePHP Convention,自動分析 Database Schema 並產生需要的程式碼。
Bake 會產生什麼?
上面的指令主要會產生:
Model
→ UsersTable
→ User Entity
Controller
→ UsersController
Templates
→ index
→ view
→ add
→ edit
Tests
→ 對應的測試檔案其中 Controller 與 Templates 會包含基本 CRUD 功能。
也就是:
Create
Read
Update
DeleteBake 的價值並不是「完全不用寫程式」,而是先幫我們建立符合 CakePHP Convention 的 Application Skeleton,再針對實際需求修改。
3. 使用 Bake 建立 Tags
接下來加入 Tags 功能。
這次使用:
bin/cake bake all tagsall 會一次產生 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 有:
funnyTag,就會顯示在結果中。
也可以:
/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_stringComputed Property。
載入 Tags Association
要計算:
$article->tag_stringArticle 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 AssociationORM 在這裡做了什麼?
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
TagsAssociation。
並實作:
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