iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Modern Web

我推的Laravel S2!系列 第 10

我推的Laravel S2|Day 09:Eloquent Model 入門:連線、Migration、關聯

  • 分享至 

  • xImage
  •  

一句話破題

從今天開始,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.phpconnections 陣列裡,DB_CONNECTION 只是指定要用哪一組。如果專案需要同時連接多個資料庫(例如一個主資料庫加一個唯讀的分析資料庫),可以在 connections 陣列裡定義多組設定,Model 層再用 protected $connection = 'analytics'; 指定要用哪一組。

SQLite 該不該用在正式環境?

這是個常被問到的問題,答案沒有絕對,但值得說清楚判斷依據。SQLite 的限制主要在並發寫入——它用檔案鎖處理併發,多個請求同時寫入時容易互相等待甚至逾時,這對流量小的個人專案、內部工具影響不大,但對「多使用者同時發文、留言」這種寫入頻繁的場景就會成為瓶頸。Laravel 官方近年其實把 SQLite 的定位拉高了不少(新專案預設值、sail/測試環境也常用它),代表對中小型專案來說它已經夠可靠,但如果 blog-app 未來真的要服務大量並發使用者,MySQL/PostgreSQL 這類客戶端-伺服器架構的資料庫,在處理併發寫入上還是更有餘裕。

建立 Model 與 Migration

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 內容:定義 posts 資料表

打開剛剛產生的 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() 自增主鍵 idbigint 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_atupdated_at 兩個欄位
$table->index([...]) 建立一般索引(非唯一),加速常見的查詢條件組合

外鍵刪除行為,不是只有 cascadeOnDelete()

->cascadeOnDelete() 是這系列選用的行為(作者被刪除,文章一起消失),但實務上還有其他選項,依業務需求選擇:

$table->foreignId('user_id')->constrained()->cascadeOnDelete();   // 級聯刪除:父紀錄刪除,子紀錄一起刪除
$table->foreignId('user_id')->constrained()->restrictOnDelete();  // 限制刪除:只要還有子紀錄引用,父紀錄就不能被刪除
$table->foreignId('user_id')->constrained()->nullOnDelete();      // 設為 null:父紀錄刪除,子紀錄的外鍵欄位變成 null(該欄位需允許 nullable)

blog-appcascadeOnDelete() 是因為「作者帳號刪除,其文章也一併清除」在部落格情境下是合理的預設行為;但如果換成「訂單」跟「客戶」的關係,你大概會想用 restrictOnDelete()——不該讓刪除一個客戶帳號就悄悄抹掉他的交易紀錄,這種情境資料完整性比使用者體驗上的方便更重要。

Migration 相關指令複習

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),避免這種操作失誤。

Factory:一行產生一批假資料

剛剛 -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 建立完整的初始資料

如果你想要一個指令就把整個開發環境的示範資料建好,用 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;
}

這裡順便建立一個全系列會用到的命名慣例:PostUser 的關聯方法命名為 author()(語意上比單純叫 user()更清楚這是「這篇文章的作者」),UserPost 的關聯方法則是慣例的 posts()

關聯型別快速對照

關聯型別 方法 範例情境
一對一 hasOne() / belongsTo() User 有一個 Profile
一對多 hasMany() / belongsTo() User 有多篇 Post(今天的範例)
多對多 belongsToMany() Post 有多個 Tag,Tag 也對應多篇 Post

實際走一遍多對多:幫 Post 加上 Tag

多對多關聯前面提到需要一張中介資料表,這裡實際示範一次,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 核心範疇),但這個模式已經建立起來,之後如果你想擴充分類、留言等功能,可以參照同樣的做法。

blog-app 資料模型關聯圖:User 一對多 Post(author),Post 多對多 Tag(透過 post_tag 中介表)

把今天建立的三張表畫成關聯圖會更容易一次記住:usersposts 是一對多(一位作者可以有多篇文章),poststags 則是多對多,中間靠 post_tag 這張只有外鍵組成複合主鍵的中介表串起來。

Model 生命週期與 Observer:簡短入門

Eloquent Model 在整個生命週期中會觸發一系列事件,完整列表如下(依觸發順序排列):

事件 觸發時機
retrieved Model 從資料庫被查詢出來之後
creating / created 新增資料之前 / 之後
updating / updated 更新資料之前 / 之後
saving / saved 新增或更新(合稱「儲存」)之前 / 之後creating/updating 都會連帶觸發這組
deleting / deleted 刪除資料之前 / 之後
restoring / restored 軟刪除的資料被復原之前 / 之後(Day 11 會講軟刪除)

Model 生命週期事件時序圖:新增觸發 creating→created,更新觸發 updating→updated,兩者都額外觸發 saving/saved;刪除觸發 deleting→deleted

你可以在 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() 方法寫法。

用 Tinker 快速驗證你寫的 Model

寫完 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
  • 關聯方法忘記回傳型別,導致 IDE 自動完成失效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、分頁一次講完。


上一篇
我推的Laravel S2|Day 08:好用的 Helpers 精選:Arr、Str、Number、Path、URL、once()
系列文
我推的Laravel S2!10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言