Toidicode.com

Toidicode.com

BASIC TO ADVANCE

Bài 18: Eloquent relationship trong Laravel 13 (Phần 2)

Ở bài trước chúng ta đã khai báo các quan hệ hasOne, hasMany, belongsTo và để lại một lỗi N+1 ở trang chi tiết project. Tiếp tục với series, bài này mình sẽ giới thiệu với mọi người phần 2 về Eloquent relationship trong Laravel 13: quan hệ nhiều-nhiều với belongsToMany, bảng pivot và eager loading.

1. Quan hệ many to many.

Một task có thể gắn nhiều tag ("frontend", "gấp"...), một tag cũng gắn cho nhiều task. Cột tag_id trong bảng tasks chỉ lưu được một tag, nên cần thêm một bảng trung gian (pivot) chứa cặp task_id, tag_id.

Tên bảng pivot mặc định là tên hai model dạng snake_case, xếp theo bảng chữ cái, nối bằng dấu _: Tag và Task thành tag_task. Mình tạo model Tag kèm migration (Bài 12) và migration cho bảng pivot:

php artisan make:model Tag -m
php artisan make:migration create_tag_task_table

- database/migrations/xxxx_create_tags_table.php và xxxx_create_tag_task_table.php (phần up())

Schema::create('tags', function (Blueprint $table) {
    $table->id();
    $table->string('name', 50)->unique();
    $table->timestamps();
});

Schema::create('tag_task', function (Blueprint $table) {
    // bảng trung gian chỉ cần hai khóa ngoại, không cần cột id
    $table->foreignId('task_id')->constrained()->cascadeOnDelete();
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->timestamps();

    // một task không gắn trùng một tag hai lần
    $table->primary(['task_id', 'tag_id']);
});

Rồi chạy php artisan migrate.

2. Khai báo belongsToMany.

Cả hai model đều dùng belongsToMany(), model Tag có thêm #[Fillable(['name'])] như Bài 15:

- app/Models/Tag.php

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

#[Fillable(['name'])]
class Tag extends Model
{
    public function tasks(): BelongsToMany
    {
        return $this->belongsToMany(Task::class)->withTimestamps();
    }
}

Model Task thêm method tags(): BelongsToMany trả về $this->belongsToMany(Tag::class)->withTimestamps() (cũng nhớ use class BelongsToMany).

Cú pháp đầy đủ là belongsToMany($related, $table, $foreignPivotKey, $relatedPivotKey).

Trong đó:

  • $table là tên bảng pivot, mặc định tag_task như trên.
  • $foreignPivotKey là cột trỏ tới model hiện tại (task_id), $relatedPivotKey là cột trỏ tới model liên quan (tag_id).
  • withTimestamps() để Eloquent tự điền created_at, updated_at của bảng pivot. Bảng pivot có thêm cột khác thì khai báo bằng withPivot('cot1', 'cot2').

Mình gắn sẵn tag cho các task bằng TagSeeder: các bạn tạo nó ở mục 6 rồi chạy php artisan migrate:fresh --seed trước khi thử. Tag là class mới nên chạy lại composer dump-autoload để tinker gọi tắt được (Bài 15). Lúc này trong php artisan tinker:

$task = Task::find(1);
$task->tags->pluck('name'); // ['viết bài', 'gấp']
Tag::where('name', 'gấp')->first()->tasks->pluck('title');
// ['Viết bài Seeder và Factory', 'Viết API danh sách task', 'Đặt phòng homestay']

// mỗi tag lấy qua belongsToMany có thêm thuộc tính pivot là dòng của bảng tag_task
$task->tags->first()->pivot->tag_id; // 3
$task->tags->first()->pivot->created_at; // thời điểm gắn tag, có nhờ withTimestamps()

hasManyThrough.

Để lấy bản ghi "cách" một bảng, như các task trong mọi project của một user (users → projects → tasks), các bạn dùng hasManyThrough($related, $through). Các khóa ngoại user_id, project_id đúng quy ước nên không cần truyền thêm:

- app/Models/User.php

use Illuminate\Database\Eloquent\Relations\HasManyThrough;

public function projectTasks(): HasManyThrough
{
    return $this->hasManyThrough(Task::class, Project::class);
}

// User::find(1)->projectTasks()->count(); // 7

3. attach, detach, sync và toggle.

Gắn và gỡ tag là thêm, xóa dòng trong bảng pivot, Eloquent có sẵn các method cho việc này (task 3 đang có tag 1 "frontend"):

$task = Task::find(3);

$task->tags()->attach([2, 4]); // gắn thêm tag 2, 4
$task->tags()->pluck('name'); // ['frontend', 'backend', 'gấp']

$task->tags()->detach(2); // 1, số dòng pivot đã xóa; detach() không tham số thì gỡ hết

// chỉ giữ đúng các tag 1, 3
$task->tags()->sync([1, 3]); // ['attached' => [3], 'detached' => [1 => 4], 'updated' => []]

// đang có thì gỡ, chưa có thì gắn
$task->tags()->toggle([1, 2]); // ['attached' => [2], 'detached' => [1]]
$task->tags()->pluck('name'); // ['backend', 'viết bài']

attach() nhận thêm mảng giá trị cho các cột khác của pivot: attach($tagId, ['cot' => $giaTri]). Gọi attach() với tag đã gắn sẽ lỗi trùng khóa chính, nên form chọn tag thường dùng sync().

4. Lỗi N+1 và eager loading.

Như Bài 17 đã nói, dynamic property chỉ query khi được đọc. Mình đếm số query bằng DB::listen() (Bài 14), các bạn chạy lại php artisan migrate:fresh --seed trước để về dữ liệu mẫu:

$count = 0;
DB::listen(function () use (&$count) {
    $count++;
});

foreach (Task::all() as $task) {
    $task->assignee?->name;
}
$count; // 7

Một query lấy 10 task, rồi mỗi task có người làm chạy thêm một query lấy user (6 task, task chưa giao không query). Đây là lỗi N+1: 1 query danh sách + N query cho N bản ghi, dữ liệu càng nhiều càng chậm.

Cách sửa là eager loading: dùng with() để lấy relationship ngay khi query model cha, Eloquent gom các khóa lại thành một câu where id in (...):

$count = 0;
foreach (Task::with('assignee')->get() as $task) {
    $task->assignee?->name;
}
$count; // 2

with() nhận nhiều relationship, relationship lồng nhau viết bằng dấu chấm, còn điều kiện cho relationship thì truyền closure:

$count = 0;
$projects = Project::with(['tasks.tags', 'owner'])->get();
$count; // 4: projects, tasks, tags, users

// chỉ eager load các task todo
Project::with(['tasks' => fn ($query) => $query->status('todo')])
    ->find(1)->tasks->pluck('title'); // ['Thiết kế trang chủ', 'Tối ưu ảnh trên trang']

// đã có model rồi thì dùng load() (lazy eager loading)
$project = Project::find(2);
$project->load('tasks.tags');
$project->tasks->sum(fn ($task) => $task->tags->count()); // 5

5. Chặn lazy loading.

Quên with() rất dễ xảy ra. Laravel cho phép biến lazy loading thành lỗi bằng Model::preventLazyLoading() trong AppServiceProvider, chỉ bật ngoài production để trên server thật trang vẫn chạy:

- app/Providers/AppServiceProvider.php

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    // RateLimiter::for('projects', ...) của Bài 4 giữ nguyên

    // ngoài production, lazy load relationship sẽ ném exception để phát hiện lỗi N+1
    Model::preventLazyLoading(! $this->app->isProduction());
}

Lúc này chạy lại vòng lặp đầu tiên ở mục 4 sẽ báo lỗi LazyLoadingViolationException: "Attempted to lazy load [assignee] on model [App\Models\Task] but lazy loading is disabled.". Model lấy riêng lẻ thì không bị chặn, Task::find(1)->assignee->name vẫn trả về Nguyễn Văn An, vì một model thì không thể có N+1.

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

TagSeeder tạo 4 tag và gắn cho 8 task, DatabaseSeeder gọi nó sau TaskSeeder:

- database/seeders/TagSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Tag;
use App\Models\Task;
use Illuminate\Database\Seeder;

class TagSeeder extends Seeder
{
    public function run(): void
    {
        // tag id 1-4
        foreach (['frontend', 'backend', 'viết bài', 'gấp'] as $name) {
            Tag::create(['name' => $name]);
        }

        // [task_id => [tag_id, ...]], task 8 và 10 không có tag
        $taskTags = [1 => [3, 4], 2 => [3], 3 => [1], 4 => [1], 5 => [2], 6 => [2, 4], 7 => [2, 3], 9 => [4]];

        foreach ($taskTags as $taskId => $tagIds) {
            Task::find($taskId)->tags()->attach($tagIds);
        }
    }
}

Sau khi bật preventLazyLoading(), tab task của /projects/1 báo lỗi ngay ở $task->assignee, đúng lỗi N+1 bài trước để lại. Mình thêm with() vào show() của ProjectController:

$tasks = $project->tasks()
    // eager load assignee và tags: thêm 2 query cho cả trang thay vì 1 query mỗi task
    ->with(['assignee', 'tags'])
    ->when(in_array($status, ['todo', 'doing', 'done'], true), fn ($query) => $query->status($status))
    ->paginate(3)
    ->withQueryString();

Rồi hiện tag sau tên người làm trong danh sách task:

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

<li>
    {{ $task->title }} <span class="text-sm text-gray-500">({{ $task->assignee?->name ?? 'Chưa giao' }})</span>
    @foreach ($task->tags as $tag)
        <span class="rounded bg-gray-100 px-2 text-xs">{{ $tag->name }}</span>
    @endforeach
</li>

Có class Tailwind mới nên các bạn chạy lại npm run build (hoặc để composer run dev chạy). Kết quả: /projects/1?tab=tasks hiện "Viết bài Seeder và Factory (Nguyễn Văn An) viết bài gấp", trang chỉ còn một query users và một query tags dù có bao nhiêu task.

7. Lời kết.

Như vậy chúng ta đã tìm hiểu xong phần 2 về Eloquent relationship trong Laravel 13: belongsToMany với bảng pivot, sync, hasManyThrough, eager loading để xử lý lỗi N+1 và preventLazyLoading(). 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ề accessor, mutator và cast, để cột status của task dùng enum TaskStatus thay cho chuỗi.

Đă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