Bài 10 — Eloquent CRUD, Cast và Mass Assignment: dùng model và factory từ bài 9 để thực hiện vòng tạo–đọc–sửa–xóa task. Bài này chạy ở tầng model trong database test riêng; chưa mở endpoint ghi công khai và form preview vẫn không lưu. Authentication cùng policy sẽ xuất hiện ở phần bảo mật.
1. Một model không phải một bảng đang nằm trong RAM
Task biểu diễn một record khi được hydrate; Task::query() là bộ dựng truy vấn. Query chỉ thực thi khi gọi các thao tác như get(), first(), find(), count() hoặc update(). Đừng gọi Task::all() để rồi lọc hàng trăm nghìn record bằng PHP. Ở TaskFlow, luôn bắt đầu từ phạm vi project khi xử lý task của project cụ thể.
Model đang giữ dữ liệu có thể cũ sau khi tiến trình khác cập nhật database. fresh() trả một instance được đọc lại; refresh() nạp lại instance hiện tại. Đọc lại giúp kiểm chứng kết quả nhưng không tự giải quyết lost update — vấn đề concurrency sẽ có bài riêng.
2. Tạo task qua quan hệ đã xác định
$task = $project->tasks()->create([
'title' => 'Review migration',
'priority' => 'normal',
'status' => 'todo',
'due_at' => '2026-10-01 09:00:00',
]);
Quan hệ tự gán project_id từ project phía server. Không lấy project_id tùy ý từ payload rồi cho vào fillable chỉ để hết lỗi. Trong ứng dụng có người dùng thật, trước bước này phải tìm project trong phạm vi được phép và authorize thao tác. Việc dùng relationship không tự tạo policy.
create() là mass assignment kết hợp lưu, nên các field cần nằm trong fillable. Model Task ở bài 9 cho phép title, description, status, priority và due_at. Danh sách đó không có nghĩa mọi màn hình đều được sửa status: mỗi Form Request vẫn cần contract riêng cho hành động tương ứng.
3. Đọc có scope và hiểu kiểu kết quả
$task = $project->tasks()->findOrFail($taskId);
$open = $project->tasks()->where('status', 'todo')
->orderBy('id')->limit(20)->get();
findOrFail nhận khóa chính và ném exception nếu không thấy; get() trả collection có thể rỗng. Scope project làm task của project khác không xuất hiện trong truy vấn, nhưng vẫn cần kiểm tra người dùng được truy cập project đó. Không dùng một ID khó đoán làm biện pháp phân quyền.
4. Cast ngày hạn và trạng thái thay đổi
Task đã khai báo due_at là immutable_datetime. Khi đọc giá trị không null, bạn làm việc với đối tượng ngày bất biến: addDay() trả giá trị mới, không sửa ngày cũ trong model. Muốn lưu ngày mới, phải gán rồi save(). Null vẫn là null nên cần kiểm tra trước khi gọi method.
$task->due_at = $task->due_at?->addDay();
$task->fill(['status' => 'done']);
$changed = $task->isDirty('status');
$task->save();
Ví dụ ngày trong bài giả định timezone ứng dụng UTC của skeleton. Khi nhận thời gian từ người dùng, xác định timezone đầu vào và chuẩn hóa trước khi lưu; một chuỗi không có offset tự nó không đủ diễn đạt ý định. Cast không thay thế validation và không tự quyết định quy ước timezone của sản phẩm. Tham khảo Eloquent casting.
5. Mass assignment không bảo vệ mọi đường ghi
fillable áp dụng cho fill/create và cập nhật qua instance dùng fill. Gán thuộc tính trực tiếp rồi save(), forceFill(), query builder hay bulk update không phải cùng cơ chế bảo vệ. Không chuyển sang forceFill($request->all()) để né lỗi. Dùng dữ liệu đã validate, chọn field theo hành động và gán ownership từ server.
Có thể bật Model::preventSilentlyDiscardingAttributes(true) trong môi trường phát triển để phát hiện field bị bỏ qua. Test bên dưới bật rồi khôi phục trong finally để không làm thay đổi các test khác. Factory thường chạy unguarded nên phải kiểm tra mass assignment bằng model thông thường, không chỉ bằng factory. Xem Eloquent.
6. Xóa và giới hạn tác động
$task = $project->tasks()->findOrFail($taskId);
$task->delete();
Task hiện chưa dùng SoftDeletes, nên đây là xóa thật record trong database test. Không chạy ví dụ này tùy tiện trên dữ liệu cần giữ. Xóa task không xóa project; ngược lại xóa project còn task bị khóa ngoại ở bài 8 chặn. Bulk update/delete có hành vi model event khác thao tác từng instance; khi xây observer phải kiểm tra đường ghi cụ thể.
7. Kiểm thử toàn vòng đời bằng dữ liệu dùng một lần
Tạo tests/Feature/TaskFlowCrudTest.php:
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Task;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\MassAssignmentException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class TaskFlowCrudTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
'database.connections.sqlite.url' => null]);
DB::purge('sqlite');
$this->artisan('migrate', ['--force' => true])->assertSuccessful();
}
public function test_create_read_update_and_delete_with_a_date_cast(): void
{
$project = Project::factory()->create();
$task = $project->tasks()->create([
'title' => 'Review migration', 'priority' => 'normal', 'status' => 'todo',
'due_at' => '2026-10-01 09:00:00',
]);
$loaded = $project->tasks()->findOrFail($task->id);
$this->assertSame('Review migration', $loaded->title);
$this->assertInstanceOf(CarbonImmutable::class, $loaded->due_at);
$nextDay = $loaded->due_at->addDay();
$this->assertSame('2026-10-01', $loaded->due_at->toDateString());
$this->assertSame('2026-10-02', $nextDay->toDateString());
$loaded->fill(['status' => 'done']);
$this->assertTrue($loaded->isDirty('status'));
$loaded->save();
$this->assertSame('done', $loaded->fresh()->status);
$this->assertDatabaseHas('tasks', ['id' => $task->id, 'status' => 'done']);
$loaded->delete();
$this->assertDatabaseMissing('tasks', ['id' => $task->id]);
$this->assertDatabaseHas('projects', ['id' => $project->id]);
}
public function test_strict_mass_assignment_rejects_project_id(): void
{
$task = new Task;
Model::preventSilentlyDiscardingAttributes(true);
try {
$this->expectException(MassAssignmentException::class);
$task->fill(['project_id' => 999, 'title' => 'Attempt']);
} finally {
Model::preventSilentlyDiscardingAttributes(false);
}
}
public function test_project_scoped_read_does_not_return_another_projects_task(): void
{
$first = Project::factory()->create();
$other = Task::factory()->create();
$this->assertNull($first->tasks()->find($other->id));
}
}php artisan test --filter=TaskFlowCrudTest
php artisan test
Toàn suite đã pass 24 test, 78 assertions. Các kiểm tra bao gồm đọc lại dữ liệu, cast bất biến, trạng thái dirty, cập nhật đã lưu, xóa task nhưng giữ project, từ chối project_id khi strict mass assignment và scope không trả task của project khác. Database SQLite bộ nhớ được chọn tường minh; không có dữ liệu local trên đĩa bị xóa.
8. Bài tập
Thêm test due_at=null; thêm test update title rồi fresh(); thử chỉ gọi fill() mà không save() để chứng minh database chưa thay đổi. Cuối cùng giải thích ba lớp khác nhau: validation kiểm tra giá trị, fillable kiểm soát gán hàng loạt, policy kiểm tra quyền. Không lớp nào tự thay thế hai lớp còn lại.
Điều hướng: Bài 9 · Lộ trình. Bài tiếp theo mở rộng và kiểm thử quan hệ Eloquent.




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