學習Laravel13+Livewire3 - 文件 #3: 模組 2：資料庫與 Eloquent ORM（6–8 小時）

Jeffery Hsu
2026-04-26T13:56:44Z

進入模組 2！資料庫與 Eloquent ORM 是 Laravel 最強大且最優美的核心之一。在這個階段，我們將實作你提到的 User、Post、Category 與 Team，並套用 Laravel 13 最新的 PHP Attributes 特性來取代傳統寫法。

為了讓你更好理解，我們先在腦海中（以及實際架構上）建立這些資料表的關聯：

Team (團隊)：一個 Team 可以有多個 User (一對多)。

User (使用者)：屬於一個 Team，並且可以發表多篇 Post (一對多)。

Category (分類)：一個 Category 包含多篇 Post (一對多)。

Post (文章)：屬於一個 User，也屬於一個 Category。

以下是逐步教學與驗證步驟：

第一步：建立 Migration、Factory 與 Model (包含 Soft Deletes)¶

我們可以使用 Artisan 指令一次性建立 Model、Migration、Factory 與 Seeder。-mfs 參數代表 (Migration, Factory, Seeder)。

php artisan make:model Team -mfs
php artisan make:model Category -mfs
php artisan make:model Post -mfs

(註：User Model 與 Migration 通常在 Laravel 預設安裝時就已存在，我們只需稍作修改。)

1. 撰寫 Migrations (定義資料表結構)
打開 database/migrations/ 下的新檔案，設定欄位。我們在這裡加入 Soft Deletes (軟刪除) 功能。

Teams Table:
Schema::create('teams', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->timestamps();
});

Users Table (修改預設):
Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->foreignId('team_id')->nullable()->constrained(); // 關聯 Team
    $table->string('name');
    $table->string('email')->unique();
    $table->string('password');
    $table->timestamps();
});

Categories Table:
Schema::create('categories', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->timestamps();
});

Posts Table:
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->foreignId('category_id')->constrained();
    $table->string('title');
    $table->text('content');
    $table->softDeletes(); // 啟用軟刪除
    $table->timestamps();
});

執行 Migration：

php artisan migrate:fresh

第二步：Laravel 13 新特性 - PHP Attributes 與 Eloquent 關聯¶

Laravel 13 引入了原生的 PHP Attributes 來取代傳統的 $table, $fillable, $hidden 等陣列屬性。這讓程式碼更乾淨，且 IDE 支援度更好（Mass Assignment 防護依然有效）。

1. Team Model

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Attributes\Fillable;

#[Fillable(['name'])] // Laravel 13 新寫法：定義可批量賦值的欄位
class Team extends Model
{
    public function users(): HasMany
    {
        return $this->hasMany(User::class);
    }
}

2. User Model

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;

#[Fillable(['name', 'email', 'password', 'team_id'])]
#[Hidden(['password', 'remember_token'])] // Laravel 13 新寫法：隱藏欄位
class User extends Authenticatable
{
    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }

    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }
}

3. Post Model (包含 Scopes 與 Events)
我們在這裡加入 Local Scope (區域查詢作用域) 與 Eloquent Events (模型事件)。

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Table;

#[Table('posts')] // 若資料表名稱不符合慣例，可用此 Attribute 指定
#[Fillable(['title', 'content', 'user_id', 'category_id'])]
class Post extends Model
{
    use SoftDeletes; // 啟用軟刪除

    // 關聯
    public function user() { return $this->belongsTo(User::class); }
    public function category() { return $this->belongsTo(Category::class); }

    // Local Scope：只查詢特定使用者的文章
    public function scopeOfUser(Builder $query, $userId): Builder
    {
        return $query->where('user_id', $userId);
    }

    // Eloquent Events：在建立文章時自動執行的邏輯
    protected static function booted(): void
    {
        static::creating(function (Post $post) {
            // 例如：如果沒有標題，給予預設值 (實務上通常用 Validation 處理，此為展示 Event)
            if (empty($post->title)) {
                $post->title = 'Untitled Post';
            }
        });
    }
}

第三步：Factory 與 Seeder 進階使用¶

我們要產生大量假資料來測試 Pagination (分頁) 與 Query Builder。

1. 設定 Factories
在 database/factories/PostFactory.php 中：

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;
use App\Models\User;
use App\Models\Category;

class PostFactory extends Factory
{
    public function definition(): array
    {
        return [
            'user_id' => User::factory(),
            'category_id' => Category::factory(),
            'title' => fake()->sentence(),
            'content' => fake()->paragraphs(3, true),
        ];
    }
}

2. 撰寫 DatabaseSeeder
在 database/seeders/DatabaseSeeder.php 組合資料：

use App\Models\Team;
use App\Models\User;
use App\Models\Category;
use App\Models\Post;

public function run(): void
{
    // 建立 2 個團隊
    $teams = Team::factory(2)->create();

    // 建立 5 個分類
    $categories = Category::factory(5)->create();

    // 建立 10 個使用者，並隨機分配團隊
    $users = User::factory(10)->recycle($teams)->create();

    // 建立 50 篇文章，重複利用上述建立的 User 和 Category，避免產生多餘資料
    Post::factory(50)->recycle($users)->recycle($categories)->create();
}

執行 Seeder：

php artisan db:seed

第四步：Query Builder 進階與 JSON:API Resources¶

現在資料庫有資料了，我們可以透過 API Resources 將 Eloquent 模型轉換為優雅的 JSON 格式，這是前後端分離非常重要的一環。

1. 建立 API Resource

php artisan make:resource PostResource

2. 設定 PostResource
打開 app/Http/Resources/PostResource.php：

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            // 處理關聯資料，避免 N+1 問題
            'author' => $this->whenLoaded('user', fn() => $this->user->name),
            'category' => $this->whenLoaded('category', fn() => $this->category->name),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

3. 在 Controller 中使用 (包含 Eager Loading 與 Pagination)

use App\Models\Post;
use App\Http\Resources\PostResource;

public function index()
{
    // Query Builder 進階：Eager Loading (with) 避免 N+1 查詢問題，並使用 Pagination 分頁
    $posts = Post::with(['user', 'category'])
                 ->latest()
                 ->paginate(10); // 每頁 10 筆

    return PostResource::collection($posts);
}

第五步：驗證作法 (使用 Tinker)¶

要驗證以上實作是否成功，最強大的工具就是 Laravel Tinker。

在終端機執行：

php artisan tinker

在 Tinker 環境中依序貼上以下指令來驗證：

// 1. 驗證關聯與 Eager Loading (應該會看到帶有 user 和 category 的文章集合)
$post = App\Models\Post::with(['user', 'category'])->first();
$post->user->name;
$post->category->name;

// 2. 驗證 Mass Assignment (Attribute 是否生效)
$newPost = App\Models\Post::create(['title' => 'Test', 'content' => 'Data', 'user_id' => 1, 'category_id' => 1]);

// 3. 驗證 Soft Deletes (軟刪除)
$newPost->delete();
App\Models\Post::find($newPost->id); // 會回傳 null
App\Models\Post::withTrashed()->find($newPost->id); // 加上 withTrashed() 才能找回

// 4. 驗證 Scope
App\Models\Post::ofUser(1)->get(); // 只撈出 user_id 為 1 的文章

只要這些 Tinker 指令都能順利執行並回傳正確結果，就代表你的資料庫結構、Eloquent 關聯、Laravel 13 的 Attributes 新寫法，以及進階查詢功能都已經完美建置成功了！

前面我們在第五步透過 Tinker 在終端機裡驗證了資料庫的運作，但對於完整的 Web 開發來說，真正的「收尾」是將這些資料實際展示在畫面上。

因此，我們的第六步：將 Eloquent 資料庫與 Flux UI 前端介面整合 (實戰收尾)。這一步會把我們在模組 2 建立的資料，結合模組 1 的介面，做一個完整的串接。

第六步：將 Eloquent 資料庫與 Flux UI 前端介面整合¶

我們現在要把剛剛透過 Factory 產生出來的 50 篇文章，搭配分頁功能 (Pagination)，漂亮地顯示在網頁上。

1. 設定路由 (Route)
打開 routes/web.php，加入文章列表的路由：

use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;

// 將 /posts 網址指派給 PostController 的 index 方法
Route::get('/posts', [PostController::class, 'index'])->name('posts.index');

2. 完善控制器 (Controller)
打開 app/Http/Controllers/PostController.php。我們稍早有寫 API Resource 版，現在我們改寫成回傳 Blade 視圖的版本（實務上如果是純 Web 專案，回傳 View 最直接）：

namespace App\Http\Controllers;

use App\Models\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function index()
    {
        // 使用 Eager Loading 載入關聯，並使用分頁（每頁 9 筆，方便排版成 3x3 網格）
        $posts = Post::with(['user', 'category'])
                     ->latest() // 依照建立時間反序排列
                     ->paginate(9);

        // 將資料傳遞給視圖
        return view('posts.index', ['posts' => $posts]);
    }
}

3. 建立 Blade 視圖並套用 Flux UI
建立一個新檔案：resources/views/posts/index.blade.php。
這裡我們將利用 Tailwind CSS 的網格系統 (Grid) 搭配 Flux UI 的卡片 (Card) 和標籤 (Badge) 元件來呈現資料：

<x-layouts.app>
    <x-slot:title>
        文章列表
    </x-slot>



            <flux:heading size="xl">最新文章</flux:heading>
            <flux:button variant="primary">新增文章</flux:button>



            @foreach ($posts as $post)
                <flux:card class="flex flex-col h-full">

                        <flux:badge color="blue" size="sm">{{ $post->category->name }}</flux:badge>
{{ $post->user->name }}


                    <flux:heading size="lg" class="mb-2">{{ $post->title }}</flux:heading>


                        {{ Str::limit($post->content, 100) }}



                        發布於 {{ $post->created_at->format('Y-m-d H:i') }}

                </flux:card>
            @endforeach



            {{ $posts->links() }}


</x-layouts.app>

4. 驗證作法 (實際在瀏覽器查看)

1. 確認你的本地伺服器正在運行（如果在不同的終端機分頁，請確認 php artisan serve 和 npm run dev 都在執行中）。

2. 打開瀏覽器，前往：http://localhost:8000/posts

你應該會看到以下成果，這代表驗證完全成功：

網頁上呈現了漂亮的卡片網格，每張卡片裡面都有文章標題、一段摘要。

卡片上方有藍色的分類標籤 (Category) 以及作者名稱 (User)，這證明了 Eloquent 關聯 (Relationships) 抓取資料非常成功。

滑到頁面最下方，你會看到美觀的分頁按鈕 (Pagination)。點擊「下一頁」，網址會自動變成 ?page=2，並且流暢地切換文章。

整個過程沒有發生任何資料庫效能卡頓，因為我們使用了 with(['user', 'category']) 解決了 N+1 查詢問題。

到這裡，模組 2 的資料庫、ORM、關聯，直到畫面呈現的資料流，已經被你徹底打通了！準備好進入下一個模組了嗎？