Lập trình · 22/09/2026

Học Laravel 13 – Bài 11: Thiết kế quan hệ dữ liệu với Eloquent trong Laravel 13

Bài 11 — Quan hệ Eloquent: đi từ schema đến cách truy vấn và thay đổi liên kết trong TaskFlow. Sau bài CRUD, chúng ta đã có Project, Task và User. Bài này bổ sung chiều quan hệ còn thiếu, kiểm tra thao tác gán người phụ trách và làm rõ một lỗi thường gặp: tưởng collection đã nạp tự cập nhật khi database thay đổi.

Thiết kế quan hệ dữ liệu với Eloquent trong Laravel 13

Bài 11 — Quan hệ Eloquent: đi từ schema đến cách truy vấn và thay đổi liên kết trong TaskFlow. Sau bài CRUD, chúng ta đã có Project, Task và User. Bài này bổ sung chiều quan hệ còn thiếu, kiểm tra thao tác gán người phụ trách và làm rõ một lỗi thường gặp: tưởng collection đã nạp tự cập nhật khi database thay đổi.

1. Đọc khóa ngoại để chọn quan hệ

Quan hệMethodKhóa ngoại thực tế
Project → ownerbelongsToprojects.owner_id
User → project sở hữuhasManyprojects.owner_id
Project → taskshasManytasks.project_id
Task → projectbelongsTotasks.project_id
Task → assigneebelongsTo, có thể nulltasks.assignee_id
User → task được giaohasManytasks.assignee_id

belongsTo nằm ở model giữ khóa ngoại. Tên owner và assignee không dùng cột user_id, nên khai báo khóa cụ thể giúp tránh suy luận nhầm. Relationship trong PHP không tự tạo foreign key database; migration bài 8 mới là nơi thực thi ràng buộc tham chiếu.

2. Thêm chiều quan hệ từ User

Trong app/Models/User.php, thêm import HasMany ở đầu file và hai method trong class. Giữ nguyên casts, thuộc tính fillable/hidden cùng các trait có sẵn:

use Illuminate\Database\Eloquent\Relations\HasMany;

// Inside App\Models\User:
public function ownedProjects(): HasMany
{
    return $this->hasMany(Project::class, 'owner_id');
}

public function assignedTasks(): HasMany
{
    return $this->hasMany(Task::class, 'assignee_id');
}

Project và Task đã có các method còn lại từ bài 9. ownedProjects() chỉ nói user sở hữu project; assignedTasks() chỉ nói user đang được giao task. Không suy ra assignee tự động là thành viên project hoặc được quyền xóa task. TaskFlow chưa có bảng membership ở mốc này.

3. Gán và bỏ gán người phụ trách

$task->assignee()->associate($assignee);
$task->save();

$task->assignee()->dissociate();
$task->save();

associate thay đổi liên kết trên model; save mới lưu khóa ngoại. dissociate đặt khóa thành null, nên cột phải nullable như schema của chúng ta. Hai thao tác không xóa user. Đừng thay bằng delete() trên model assignee khi ý định chỉ là bỏ giao việc.

Ví dụ test dùng user được tạo riêng. Trong nghiệp vụ thật cần kiểm tra người thực hiện có quyền gán, người nhận hợp lệ với project và trạng thái task cho phép thay đổi. Foreign key chỉ bảo đảm user tồn tại. Không lấy assignee_id trực tiếp từ form rồi xem việc save thành công là bằng chứng phân quyền.

4. Method quan hệ và property quan hệ không giống nhau

$project->tasks()->where('status', 'todo')->count();
$project->tasks;
$project->load('tasks');

Dòng đầu tạo truy vấn có ràng buộc và tính count ở database. Dòng thứ hai trả collection quan hệ, có thể lazy load lần đầu và được giữ trong instance. Dòng thứ ba nạp lại quan hệ. Nếu đã đọc collection rỗng rồi một thao tác khác thêm task, collection cũ không tự biết dữ liệu mới.

Đây không phải cache Redis hay lỗi transaction. Nó là trạng thái object trong PHP. loadMissing() chỉ hữu ích khi quan hệ chưa được nạp, không phải lệnh bắt buộc làm mới một collection đã có. Khi hiển thị danh sách, phải chọn chiến lược nạp rõ ràng; bài N+1 tiếp theo sẽ đo số truy vấn thay vì đoán.

5. Xóa người phụ trách khác xóa owner

Schema dùng nullOnDelete cho assignee_id, vì task vẫn có giá trị khi người phụ trách rời hệ thống. owner_id của project dùng restrictOnDelete: user đang sở hữu project không bị xóa bằng thao tác đơn giản. Một user vừa là assignee vừa là owner có thể bị chặn xóa bởi quan hệ owner; test bên dưới cố ý dùng assignee không sở hữu project.

Không đặt cascade khắp nơi để làm test xóa pass. Chính sách xóa phải xuất phát từ yêu cầu giữ dữ liệu và luồng chuyển giao sở hữu. Cũng không coi nullOnDelete là một thông báo nghiệp vụ: database cập nhật khóa ngoại không có nghĩa từng Task observer được gọi như khi ứng dụng save từng model.

6. Kiểm thử quan hệ bằng database riêng

Tạo tests/Feature/TaskFlowRelationshipsTest.php. Test thiết lập connection bộ nhớ trước migration:

<?php

namespace Tests\Feature;

use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class TaskFlowRelationshipsTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
            'database.connections.sqlite.url' => null, 'database.connections.sqlite.foreign_key_constraints' => true]);
        DB::purge('sqlite');
        $this->artisan('migrate', ['--force' => true])->assertSuccessful();
    }

    public function test_inverse_relationships_and_assignment_round_trip(): void
    {
        $project = Project::factory()->create();
        $task = Task::factory()->for($project)->create();
        $assignee = User::factory()->create();
        $this->assertTrue($project->owner->ownedProjects()->whereKey($project->id)->exists());
        $this->assertTrue($task->project->is($project));

        $task->assignee()->associate($assignee);
        $task->save();
        $this->assertTrue($assignee->assignedTasks()->whereKey($task->id)->exists());
        $this->assertTrue($task->fresh()->assignee->is($assignee));

        $task->assignee()->dissociate();
        $task->save();
        $this->assertNull($task->fresh()->assignee);
    }

    public function test_deleting_only_an_assignee_keeps_the_task(): void
    {
        $assignee = User::factory()->create();
        $task = Task::factory()->for($assignee, 'assignee')->create();
        $assignee->delete();
        $this->assertDatabaseHas('tasks', ['id' => $task->id, 'assignee_id' => null]);
    }

    public function test_relationship_query_is_not_a_cached_collection(): void
    {
        $project = Project::factory()->create();
        $this->assertCount(0, $project->tasks);
        Task::factory()->for($project)->create();
        $this->assertSame(1, $project->tasks()->count());
        $this->assertCount(0, $project->tasks);
        $project->load('tasks');
        $this->assertCount(1, $project->tasks);
    }
}
php artisan test --filter=TaskFlowRelationshipsTest
php artisan test

Toàn suite pass 27 test, 91 assertions. Test xác minh chiều ngược từ owner, project của task, vòng associate/dissociate, null-on-delete và sự khác nhau giữa query mới với collection cũ. Đây là bằng chứng trên SQLite; không phải kiểm chứng policy hoặc toàn bộ hành vi của một production database khác.

7. Khi nào cần many-to-many?

Nếu sản phẩm cần nhiều thành viên trong một project và mỗi user tham gia nhiều project, hãy thiết kế bảng project_user với unique(project_id, user_id), các foreign key và cột vai trò nếu cần. Đó là mô hình khác với owner duy nhất và assignee của task. Chưa thêm pivot chỉ để đủ mọi loại quan hệ trong một bài; trước hết phải thống nhất ý nghĩa membership và quyền.

Tương tự, không chọn polymorphic chỉ vì có nhiều loại dữ liệu. Cần cân nhắc khả năng áp ràng buộc tham chiếu, truy vấn và thay đổi loại model. Xem tài liệu Eloquent relationships để mở rộng sau khi quan hệ hiện tại đã rõ.

8. Bài tập

Tạo hai project cùng owner, đếm ownedProjects bằng query và kiểm tra mỗi project chỉ thấy task của mình. Nạp tasks, thêm task, so sánh count trước và sau load(). Cuối cùng thử xóa user vừa sở hữu project vừa được giao task trong test dùng một lần và giải thích constraint nào chặn thao tác.

Điều hướng: Bài 10 · Lộ trình. Bài tiếp theo đo N+1 và áp dụng eager loading.

Điều hướng khóa học Laravel 13

Bài trước (10) · Bài sau (12) · Mục lục trọn bộ 37 bài

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.