Toidicode.com

Toidicode.com

BASIC TO ADVANCE

Bài 17: Eloquent relationship trong Laravel 13 (Phần 1)

Ở bài trước chúng ta đã lọc và phân trang task, nhưng vẫn phải viết Task::where('project_id', $project->id) ở nhiều chỗ. Tiếp tục với series, bài này mình sẽ giới thiệu với mọi người về Eloquent relationship trong Laravel 13 với ba quan hệ cơ bản: hasOne, hasMany và belongsTo.

1. Eloquent relationship là gì?

Relationship (quan hệ) là mối liên kết giữa các bảng qua khóa ngoại, được khai báo trong model để lấy dữ liệu liên quan bằng object thay vì tự viết where.

Bảng tasks của TaskFlow có khóa ngoại project_id, assignee_id, bảng projects có user_id (Bài 12). Các quan hệ hay gặp:

  • One to one (1-1): một user có một hồ sơ, dùng hasOne.
  • One to many (1-n): một project có nhiều task, dùng hasMany. Chiều ngược lại, mỗi task thuộc về một project, dùng belongsTo.
  • Many to many (n-n): như task và tag, mình sẽ giới thiệu ở Bài 18.

2. One to one với hasOne.

Giả sử có bảng profiles (cột user_id, phone...) lưu thêm thông tin của user, mỗi user một dòng. TaskFlow chưa cần bảng này nên mình chỉ ví dụ cách khai báo, relationship là một method public trong model:

use Illuminate\Database\Eloquent\Relations\HasOne;

class User extends Authenticatable
{
    public function profile(): HasOne
    {
        return $this->hasOne(Profile::class);
    }
}

Cú pháp đầy đủ là $this->hasOne($related, $foreignKey, $localKey).

Trong đó:

  • $related là model liên quan.
  • $foreignKey là cột khóa ngoại trong bảng của $related, mặc định là tên model hiện tại dạng snake_case + _id: User thành user_id.
  • $localKey là cột mà khóa ngoại trỏ tới, mặc định là khóa chính id.

Lúc này User::find(1)->profile trả về object Profile có user_id là 1, hoặc null nếu chưa có.

3. One to many với hasMany và belongsTo.

hasMany() nhận tham số giống hasOne(), mình thêm method tasks() vào model Project, khóa ngoại mặc định là project_id:

- app/Models/Project.php

use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Project extends Model
{
    public function tasks(): HasMany
    {
        return $this->hasMany(Task::class);
    }

    // chủ project, khóa ngoại là cột user_id chứ không phải owner_id
    public function owner(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}

Chiều ngược lại là belongsTo($related, $foreignKey, $ownerKey), đặt ở model chứa khóa ngoại. Khác với hasMany, khóa ngoại mặc định lấy từ tên method + _id, nên owner() ở trên phải truyền 'user_id', nếu không Eloquent sẽ tìm cột owner_id. Còn trong model Task, method assignee() tự khớp với cột assignee_id:

- app/Models/Task.php

public function project(): BelongsTo
{
    return $this->belongsTo(Project::class);
}

public function assignee(): BelongsTo
{
    return $this->belongsTo(User::class);
}

Model User có thêm projects() là hasMany(Project::class) và assignedTasks() là hasMany(Task::class, 'assignee_id') (cột không phải user_id nên phải truyền). Ở Task, User cũng nhớ use class BelongsTo, HasMany như trên, thiếu thì PHP hiểu kiểu trả về là App\Models\BelongsTo và báo TypeError.

4. Dynamic property và method.

Relationship đọc như property (dynamic property) thì trả về kết quả, gọi như method thì trả về query (chạy trong php artisan tinker với dữ liệu mẫu):

$project = Project::find(1);
$project->tasks->count(); // 4
$project->tasks()->status('todo')->pluck('title'); // ['Thiết kế trang chủ', 'Tối ưu ảnh trên trang']

$task = Task::find(1);
$task->project->name; // Website toidicode.com
$task->assignee->name; // Nguyễn Văn An
Task::find(3)->assignee; // null, chưa giao
Project::find(1)->owner->name; // Nguyễn Văn An
User::find(1)->projects->pluck('name'); // ['Website toidicode.com', 'TaskFlow API']

Trong đó:

  • $project->tasks chạy query ở lần đọc đầu tiên và trả về collection (Bài 16), các lần sau dùng lại kết quả đó. Với belongsTo, hasOne thì kết quả là một model hoặc null.
  • $project->tasks() trả về object HasMany: một query đã có sẵn điều kiện project_id = 1, nối tiếp được where(), scope, paginate()...

Lưu ý: Dynamic property chỉ query khi được đọc (lazy loading). Duyệt 10 task và đọc $task->assignee trong vòng lặp sẽ chạy thêm 10 query, đây là lỗi N+1 mà Bài 18 sẽ xử lý bằng eager loading.

5. Thêm dữ liệu qua relationship.

Gọi create() hoặc save() trên relationship thì Eloquent tự điền khóa ngoại project_id. Với belongsTo thì dùng associate() để gán model cha, dissociate() để bỏ:

$project = Project::find(2);
$task = $project->tasks()->create(['title' => 'Viết test cho API']);
$task->project_id; // 2

$project->tasks()->save(new Task(['title' => 'Deploy API']));
$project->tasks()->count(); // 5

$task->assignee()->associate(User::find(1)); // gán assignee_id = 1
$task->save();
$task->assignee->name; // Nguyễn Văn An

$task->assignee()->dissociate(); // assignee_id = null
$task->save();

create() nhận mảng (đi qua #[Fillable]), save() nhận object, cả hai lưu luôn vào database. Còn associate() chỉ gán giá trị nên phải gọi save().

Factory với for() và has().

Factory (Bài 13) cũng hiểu relationship: for() chỉ định model cha, has() tạo kèm các model con:

$project = Project::factory()
    ->for(User::find(1), 'owner')
    ->has(Task::factory()->count(3))
    ->create();

$project->owner->name; // Nguyễn Văn An
$project->tasks()->count(); // 3

has() tự đoán tên relationship là tasks từ model Task, các task dùng luôn project vừa tạo chứ không tạo project mới như mặc định của TaskFactory. for() đoán là user nên phải truyền tên 'owner'.

Hai seeder của TaskFlow cũng đổi sang for(), dữ liệu giữ nguyên: ProjectSeeder dùng for(User::find(1), 'owner') thay cho cột user_id, TaskSeeder dùng for($project) thay cho project_id.

6. Truy vấn theo relationship.

has() chỉ lấy các bản ghi có bản ghi liên quan, whereHas() thêm điều kiện cho bản ghi liên quan, doesntHave() thì ngược lại. withCount() thêm cột {relation}_count đếm bản ghi liên quan (mình chạy lại php artisan migrate:fresh --seed để về dữ liệu mẫu):

use Illuminate\Database\Eloquent\Builder;

// project có từ 4 task trở lên
Project::has('tasks', '>=', 4)->pluck('name'); // ['Website toidicode.com']

// project có task đến hạn trong 3 ngày tới
Project::whereHas('tasks', function (Builder $query) {
    $query->where('due_date', '<=', now()->addDays(3)->toDateString());
})->pluck('name'); // ['Website toidicode.com', 'Du lịch Đà Lạt']

Project::withCount('tasks')->pluck('tasks_count', 'name');
// ['Website toidicode.com' => 4, 'TaskFlow API' => 3, 'Du lịch Đà Lạt' => 3]

// đổi tên cột đếm, thêm điều kiện
Project::withCount(['tasks as done_count' => fn (Builder $query) => $query->status('done')])
    ->first()->done_count; // 1

withCount() đếm bằng subquery nên cả danh sách chỉ cần một query, task đã xóa mềm không được tính.

7. Áp dụng vào TaskFlow.

Giờ mình thay các đoạn Task::where('project_id', ...) của Bài 15:

- app/Http/Controllers/ProjectController.php

// index(): đếm task của mỗi project trong cùng câu query
$projects = Project::withCount('tasks')->paginate(5);

// show(): bộ lọc và phân trang của Bài 16 giữ nguyên
$tasks = $project->tasks()
    ->when(in_array($status, ['todo', 'doing', 'done'], true), fn ($query) => $query->status($status))
    ->paginate(3)
    ->withQueryString();

ListProjectTasks đổi thành return $project->tasks()->pluck('title');. Method taskCount() của component ProjectCard thành return $this->project->tasks_count ?? $this->project->tasks()->count();, có cột của withCount() thì dùng luôn thay vì chạy một query count cho mỗi card.

Trang chi tiết hiện thêm chủ project và người được giao của từng task:

- resources/views/projects/show.blade.php

<p>Chủ project: {{ $project->owner?->name ?? 'Chưa có' }}</p>

{{-- trong tab task --}}
<li>{{ $task->title }} <span class="text-sm text-gray-500">({{ $task->assignee?->name ?? 'Chưa giao' }})</span></li>

Kết quả: /projects/1 hiện "Chủ project: Nguyễn Văn An", tab task hiện "Thiết kế trang chủ (Chưa giao)", còn /projects vẫn hiện "4 task", "3 task" nhưng không còn query count riêng cho từng card.

8. Lời kết.

Như vậy chúng ta đã tìm hiểu xong phần 1 về Eloquent relationship trong Laravel 13: hasOne, hasMany, belongsTo, dynamic property, thêm dữ liệu và truy vấn với has(), whereHas(), withCount(). Các bạn xem thêm tại tài liệu chính thức. Bài sau mình sẽ giới thiệu về quan hệ nhiều-nhiều với belongsToMany, bảng pivot để gắn tag cho task và eager loading để xử lý lỗi N+1.

Đăng ký nhận tin.

Chúng tôi chỉ gửi tối đa 2 lần trên 1 tháng. Tuyên bố không spam mail!

Vũ Thanh Tài

About author
The best way to learn is to share
Xem tất cả bài đăng

0 Comments

Bài viết chưa có ai bình luận, hãy là người đầu tiên đi bạn!

Bình luận

Captcha