從今天開始,blog-app 要有真正的資料庫了。Eloquent 是 Laravel 的 ORM(物件關聯對映),讓你用 PHP 物件的方式操作資料庫資料表,不用手寫大量 SQL。今天把 Post(文章)與 User(作者)兩個模型從零建起來,這會是後面十幾天範例的資料基礎。
.env 裡的 DB_* 系列變數控制資料庫連線,Laravel 13 新專案預設用 SQLite(延續 Laravel 11 起的預設值):
DB_CONNECTION=sqlite
SQLite 的好處是零設定——不需要另外啟動一套資料庫服務,database/database.sqlite 這個檔案本身就是整個資料庫。這對本地開發、小型專案、或這系列的教學範例來說非常方便。如果 blog-app 之後要接近正式環境的樣貌,可以改用 MySQL:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=blog_app
DB_USERNAME=root
DB_PASSWORD=
實際對應的連線細節定義在 config/database.php 的 connections 陣列裡,DB_CONNECTION 只是指定要用哪一組。如果專案需要同時連接多個資料庫(例如一個主資料庫加一個唯讀的分析資料庫),可以在 connections 陣列裡定義多組設定,Model 層再用 protected $connection = 'analytics'; 指定要用哪一組。
這是個常被問到的問題,答案沒有絕對,但值得說清楚判斷依據。SQLite 的限制主要在並發寫入——它用檔案鎖處理併發,多個請求同時寫入時容易互相等待甚至逾時,這對流量小的個人專案、內部工具影響不大,但對「多使用者同時發文、留言」這種寫入頻繁的場景就會成為瓶頸。Laravel 官方近年其實把 SQLite 的定位拉高了不少(新專案預設值、sail/測試環境也常用它),代表對中小型專案來說它已經夠可靠,但如果 blog-app 未來真的要服務大量並發使用者,MySQL/PostgreSQL 這類客戶端-伺服器架構的資料庫,在處理併發寫入上還是更有餘裕。
make:model 指令可以一次順便建立 Migration、Factory、Controller:
php artisan make:model Post -mfcr
-m:同時建立對應的 Migration-f:同時建立 Factory(產生假資料用,Day 27 寫測試時會大量用到)-c:同時建立 Controller(Day 13 會用到)-r:Controller 建立成 Resource Controller(對應 Day 7 定義的 Route::resource)執行後會產生:
app/Models/Post.php
database/migrations/xxxx_xx_xx_create_posts_table.php
database/factories/PostFactory.php
app/Http/Controllers/PostController.php
(想一次全上,-a 會連 Seeder、Policy、Form Request 都幫你產生;記不住旗標的話 php artisan make:model --help 隨時可查。)
User 模型是 Laravel 新專案本來就內建的(app/Models/User.php),不需要另外建立,我們只需要幫它補上跟 Post 的關聯。
打開剛剛產生的 Migration 檔案,把 up() 方法改成:
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->string('slug')->unique();
$table->text('body');
$table->boolean('is_featured')->default(false);
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'published_at']); // 常見查詢「某作者的已發布文章」對應的複合索引
});
}
幾個常用欄位型別與修飾子複習:
| 寫法 | 用途 |
|---|---|
$table->id() |
自增主鍵 id(bigint unsigned) |
$table->foreignId('user_id')->constrained() |
建立外鍵欄位並自動關聯到 users.id |
->cascadeOnDelete() |
當關聯的 User 被刪除時,連帶刪除這篇 Post |
$table->string('title') |
預設長度 255 的字串欄位 |
$table->string('slug')->unique() |
網址用的代稱,加上唯一索引避免重複 |
$table->text('body') |
長文字欄位,適合放文章內容 |
$table->boolean('is_featured')->default(false) |
是否為精選文章,預設否 |
$table->timestamp('published_at')->nullable() |
可為 null 的時間戳記,null 代表尚未發布(草稿) |
$table->timestamps() |
自動加上 created_at、updated_at 兩個欄位 |
$table->index([...]) |
建立一般索引(非唯一),加速常見的查詢條件組合 |
->cascadeOnDelete() 是這系列選用的行為(作者被刪除,文章一起消失),但實務上還有其他選項,依業務需求選擇:
$table->foreignId('user_id')->constrained()->cascadeOnDelete(); // 級聯刪除:父紀錄刪除,子紀錄一起刪除
$table->foreignId('user_id')->constrained()->restrictOnDelete(); // 限制刪除:只要還有子紀錄引用,父紀錄就不能被刪除
$table->foreignId('user_id')->constrained()->nullOnDelete(); // 設為 null:父紀錄刪除,子紀錄的外鍵欄位變成 null(該欄位需允許 nullable)
blog-app 選 cascadeOnDelete() 是因為「作者帳號刪除,其文章也一併清除」在部落格情境下是合理的預設行為;但如果換成「訂單」跟「客戶」的關係,你大概會想用 restrictOnDelete()——不該讓刪除一個客戶帳號就悄悄抹掉他的交易紀錄,這種情境資料完整性比使用者體驗上的方便更重要。
php artisan migrate # 執行所有尚未執行過的 migration
php artisan migrate:status # 查看目前 migration 執行狀態
php artisan migrate:rollback # 回復上一批次的 migration
php artisan migrate:rollback --step=2 # 回復最近兩批次
php artisan migrate:fresh # 砍掉所有資料表重新跑一次 migration(開發階段常用,正式環境絕對不要用)
php artisan migrate:fresh --seed # fresh 之後順便執行 Seeder
php artisan migrate --pretend # 不實際執行,只印出即將跑的 SQL,適合先確認再動手
migrate:fresh 這個指令要特別小心——它會直接砍掉資料庫裡所有的表再重建,本機開發資料亂了想重新來過非常方便,但如果你不小心在連著正式環境資料庫的終端機視窗打了這個指令,後果不堪設想。實務上會建議在正式環境的部署腳本裡明確限制只能執行 migrate(不帶 fresh),避免這種操作失誤。
剛剛 -f 產生的 database/factories/PostFactory.php,負責定義「一筆假的 Post 長什麼樣子」:
// database/factories/PostFactory.php
class PostFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'title' => fake()->sentence(),
'slug' => fake()->unique()->slug(),
'body' => fake()->paragraphs(3, asText: true),
'is_featured' => false,
'published_at' => fake()->boolean(70) ? now() : null,
];
}
// 自訂狀態:需要「一定是已發布」的文章時使用
public function published(): static
{
return $this->state(fn (array $attributes) => [
'published_at' => now(),
]);
}
// 自訂狀態:需要「一定是草稿」的文章時使用
public function draft(): static
{
return $this->state(fn (array $attributes) => [
'published_at' => null,
]);
}
}
user_id 直接寫 User::factory(),代表「順便幫這篇文章造一個作者出來」,不需要自己先建 User 再手動帶 ID。用起來像這樣:
Post::factory()->create(); // 造一筆並寫進資料庫
Post::factory()->count(10)->create(); // 造十筆
Post::factory()->create(['published_at' => null]); // 指定欄位,其餘照 definition 隨機
Post::factory()->make(); // 只造物件不寫進資料庫
Post::factory()->published()->count(5)->create(); // 用上面定義的自訂狀態,造 5 筆一定是已發布的文章
Post::factory()->for(User::factory()->state(['name' => '測試作者']))->create(); // 指定特定作者屬性
Factory 在兩個地方特別有價值:本機開發時快速塞一批資料進去看畫面排版(搭配 Seeder),以及 Day 27 寫測試時準備測試資料——那天你會看到幾乎每個測試案例開頭都是一行 Post::factory()。published()/draft() 這種自訂狀態方法,讓測試案例可以清楚表達「我需要的是已發布的文章」而不是靠隨機機率賭一把,這在 Day 27 寫斷言(assertion)時特別重要,因為測試需要可預期、可重現的資料狀態。
如果你想要一個指令就把整個開發環境的示範資料建好,用 Seeder 把多個 Factory 呼叫組織起來:
// database/seeders/DatabaseSeeder.php
public function run(): void
{
$users = User::factory()->count(5)->create();
$users->each(function (User $user) {
Post::factory()
->for($user)
->count(fake()->numberBetween(3, 10))
->published()
->create();
});
}
php artisan db:seed # 執行 DatabaseSeeder
php artisan migrate:fresh --seed # 常見組合:重建資料庫並立即填入示範資料
->for($user) 是 Factory 的一個實用方法,代表「這批 Post 都屬於這個已經建好的 $user」,不需要每次都讓 Factory 自己隨機生一個作者出來——這在需要「同一位作者有多篇文章」這種有意義的資料結構時特別有用,比完全隨機的資料更適合拿來手動測試畫面。
Post 屬於一位 User(多對一),User 擁有多篇 Post(一對多)。在 Eloquent 裡,這組關聯要在兩個 Model 裡分別定義:
// app/Models/Post.php
class Post extends Model
{
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}
// app/Models/User.php
class User extends Authenticatable
{
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
定義好之後,就能用屬性的方式取得關聯資料,不需要自己寫 JOIN:
$post = Post::find(1);
echo $post->author->name; // 取得這篇文章的作者名稱
$user = User::find(1);
foreach ($user->posts as $post) {
echo $post->title;
}
這裡順便建立一個全系列會用到的命名慣例:Post 對 User 的關聯方法命名為 author()(語意上比單純叫 user()更清楚這是「這篇文章的作者」),User 對 Post 的關聯方法則是慣例的 posts()。
| 關聯型別 | 方法 | 範例情境 |
|---|---|---|
| 一對一 | hasOne() / belongsTo() |
User 有一個 Profile |
| 一對多 | hasMany() / belongsTo() |
User 有多篇 Post(今天的範例) |
| 多對多 | belongsToMany() |
Post 有多個 Tag,Tag 也對應多篇 Post |
多對多關聯前面提到需要一張中介資料表,這裡實際示範一次,blog-app 之後想做標籤功能時可以直接套用這個模式:
php artisan make:model Tag -m
php artisan make:migration create_post_tag_table
// posts、tags 各自的 migration 略(跟 posts 類似的基本結構)
// create_post_tag_table migration
Schema::create('post_tag', function (Blueprint $table) {
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
$table->primary(['post_id', 'tag_id']); // 複合主鍵,同一組 post_id + tag_id 不能重複
});
中介表的命名慣例是把兩個相關資料表名稱依字母順序排列、用底線連接(post_tag,不是 tag_post),Eloquent 預設會依照這個慣例自動猜到表名,不需要額外指定:
// app/Models/Post.php
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
// app/Models/Tag.php
public function posts(): BelongsToMany
{
return $this->belongsToMany(Post::class);
}
使用方式:
$post->tags()->attach($tagId); // 建立一筆關聯
$post->tags()->attach([1, 2, 3]); // 一次建立多筆
$post->tags()->detach($tagId); // 移除一筆關聯
$post->tags()->sync([1, 2, 3]); // 同步成只保留這幾個 ID(沒列出的既有關聯會被移除)
$post->tags; // 讀取這篇文章的所有標籤(Collection)
sync() 是實務上最常用的方法——例如編輯文章的標籤表單送出時,不需要自己比對「哪些要新增、哪些要刪除」,直接把表單勾選的完整標籤 ID 陣列丟給 sync(),Eloquent 會自動處理差異。
多對多關聯需要一張中介資料表,這系列不會每天都用到 Tag(避免超出 blog-app 核心範疇),但這個模式已經建立起來,之後如果你想擴充分類、留言等功能,可以參照同樣的做法。

把今天建立的三張表畫成關聯圖會更容易一次記住:users 跟 posts 是一對多(一位作者可以有多篇文章),posts 跟 tags 則是多對多,中間靠 post_tag 這張只有外鍵組成複合主鍵的中介表串起來。
Eloquent Model 在整個生命週期中會觸發一系列事件,完整列表如下(依觸發順序排列):
| 事件 | 觸發時機 |
|---|---|
retrieved |
Model 從資料庫被查詢出來之後 |
creating / created |
新增資料之前 / 之後 |
updating / updated |
更新資料之前 / 之後 |
saving / saved |
新增或更新(合稱「儲存」)之前 / 之後,creating/updating 都會連帶觸發這組 |
deleting / deleted |
刪除資料之前 / 之後 |
restoring / restored |
軟刪除的資料被復原之前 / 之後(Day 11 會講軟刪除) |

你可以在 Model 裡直接用 booted() 方法監聽:
// app/Models/Post.php
protected static function booted(): void
{
static::creating(function (Post $post) {
// 沒有指定網址代稱時,自動從標題產生一組
$post->slug ??= Str::slug($post->title);
});
}
Day 8 提過 Str::slug() 預設會把中文轉寫成拉丁拼音,這在 blog-app 這種中文部落格會產生兩個實際問題:轉寫結果可能很短、甚至是空字串(標題全是符號或表情符號時),而 slug 欄位有唯一索引,兩篇標題轉寫後撞在一起就會直接寫入失敗。實務上會補一層防呆:
use Illuminate\Support\Str;
protected static function booted(): void
{
static::creating(function (Post $post) {
if ($post->slug) {
return;
}
// 保留中文本身(第三參數傳 null),避免拼音轉寫後可讀性歸零
$base = Str::slug($post->title, '-', null);
// 轉寫結果為空(標題全是符號/表情符號)時,退回一組隨機碼
$base = $base !== '' ? $base : Str::random(8);
// 撞到既有代稱就補一段亂數,確保不違反唯一索引
$post->slug = Post::where('slug', $base)->exists()
? $base.'-'.Str::random(4)
: $base;
});
}
這段程式碼刻意保留得很直白,好讓你看清楚它在防哪三件事:沒填就自動產生、產不出東西就給退路、撞名就加後綴。正式專案如果併發建立文章的機率高,「先查再寫」這種寫法本身仍有極小的競爭條件風險,最穩妥的做法是讓資料庫的唯一索引當最後一道防線,並在捕捉到違反唯一約束的例外時重試一次。
如果同一個 Model 有比較多生命週期邏輯要處理,把邏輯全部寫在 Model 裡會讓檔案越來越肥,這時候可以抽成一個獨立的 Observer 類別:
php artisan make:observer PostObserver --model=Post
// app/Observers/PostObserver.php
class PostObserver
{
public function creating(Post $post): void
{
$post->slug ??= Str::slug($post->title);
}
public function updated(Post $post): void
{
Cache::forget("post.{$post->id}"); // 內容變了就讓快取失效
}
public function deleted(Post $post): void
{
Log::info("Post #{$post->id} 已被刪除");
}
}
// app/Models/Post.php
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
#[ObservedBy([PostObserver::class])]
class Post extends Model
{
// ...
}
#[ObservedBy] 是 PHP Attribute 語法,好處是註冊資訊直接寫在 Model 類別上,不需要像早期做法那樣跑到 AppServiceProvider::boot() 裡呼叫 Post::observe(PostObserver::class)——兩種寫法都還能用,但 Attribute 寫法讓「這個 Model 有哪些 Observer」一眼就能從 Model 檔案本身看出來,是目前比較建議的方式。
Observer 適合「這個 Model 的生命週期邏輯多到值得獨立管理」的情境(例如同時要處理快取清除、發通知、記錄稽核日誌);只有一兩行邏輯的話,直接寫在 booted() 裡更省事。屬性轉型(Attribute Casting,例如把 published_at 自動轉換成 Carbon 物件)我們留到 Day 11 深入,那天也會講到 Laravel 11 新增的 casts() 方法寫法。
寫完 Model 跟關聯之後,不需要真的透過瀏覽器點來點去才能驗證邏輯對不對,php artisan tinker 提供一個互動式的 PHP shell,可以直接操作你的 Model:
php artisan tinker
>>> $user = User::factory()->create();
>>> $post = Post::factory()->for($user)->create();
>>> $post->author->name
=> "測試使用者"
>>> $user->posts->count()
=> 1
>>> Post::whereNotNull('published_at')->count()
=> 0
Tinker 在快速驗證 Migration、Model 關聯、Eloquent 查詢語法是否正確時非常實用,比起每次都要跑起完整的網頁流程才能確認一行程式碼的行為,Tinker 讓你能在幾秒鐘內得到回饋——這系列接下來每一天介紹新的 Eloquent 語法時,都建議你直接打開 Tinker 跟著實際打一次,會比只看文章裡的程式碼片段更容易記住。
->constrained() 導致外鍵沒有實際約束:foreignId('user_id') 只是建立一個 bigint unsigned 欄位,沒有 constrained() 的話不會產生真正的外鍵約束,資料庫層面不會擋掉無效的 user_id。belongsTo/hasMany 等回傳型別建議明確標註(如範例的 : BelongsTo),現代 IDE 跟 Laravel 生態的靜態分析工具都仰賴這個型別提示。published_at 欄位忘記設 nullable():如果這個欄位不允許 null,就沒辦法用「是否為 null」來表示「草稿 vs 已發布」這個常見的業務邏輯。migrate:fresh:這個指令會砍掉所有資料表重建,前面已經強調過,這裡再次提醒——部署腳本應該明確限制只能跑 migrate,避免操作失誤造成資料全滅的災難。$table->primary(['post_id', 'tag_id']) 這類約束,同一組 post_id/tag_id 可能被重複寫入好幾筆,attach() 呼叫多次會累積出重複紀錄,sync() 則不會有這個問題(它會先清除再重建)。posts 資料表如果常態查詢「某作者的已發布文章」,卻沒有對應的複合索引,資料庫在資料量小的時候感覺不出差異,但成長到幾萬筆之後查詢效能會明顯下降,索引策略建議在設計 Migration 的當下就一併考慮,而不是等效能出問題才回頭補。今天建立了 blog-app 核心的 Post/User 模型與資料表結構,定義了一對多的作者關聯,也實際走過多對多關聯的完整實作(Tag),並認識了 Model 生命週期事件的完整列表、Observer 的角色分工,以及 Tinker 這個開發階段驗證邏輯的實用工具。這個資料模型會是接下來 Day 10 查詢建構器、Day 11 Eloquent 進階操作的基礎。
問渠哪得清如許?為有源頭活水來 — 朱熹
Day 10 我們用剛建好的 Post/User 資料,把查詢建構器最常用的方法整理成一份速查表:條件、排序、Join、分頁一次講完。