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

Laravel 13 Course – Lesson 10: Laravel 13 Eloquent CRUD, Casts and Mass Assignment

Lesson 10 — Eloquent CRUD, Casts and Mass Assignment: use the models and factories from lesson 9 for a create–read–update–delete cycle. This is model-layer work in isolated tests, not a public write endpoint. The preview form still does not persist; authentication and policies come later.

Eloquent CRUD, Cast và Mass Assignment trong Laravel 13

Lesson 10 — Eloquent CRUD, Casts and Mass Assignment: use the models and factories from lesson 9 for a create–read–update–delete cycle. This is model-layer work in isolated tests, not a public write endpoint. The preview form still does not persist; authentication and policies come later.

1. A model is not an entire table in memory

A hydrated Task represents a record; Task::query() constructs a query. Operations such as get(), first(), find(), count() or update() execute it. Do not load a huge table with all() merely to filter it in PHP. Start from a project relationship when working with that project's tasks.

An in-memory model can become stale after another process updates the database. fresh() returns a reloaded instance; refresh() reloads the current one. Rereading verifies state but does not prevent lost updates; concurrency needs its own design.

2. Create through a known relationship

$task = $project->tasks()->create([
    'title' => 'Review migration',
    'priority' => 'normal',
    'status' => 'todo',
    'due_at' => '2026-10-01 09:00:00',
]);

The relationship assigns project_id from the server-selected project. Do not make arbitrary payload ownership IDs fillable just to suppress an error. A real user-facing action must first locate an accessible project and authorize the operation. Relationships do not automatically provide policies.

create() combines mass assignment with persistence. The previous Task model allows title, description, status, priority and due_at. That does not mean every form may modify status; each action still needs an appropriate validation contract.

3. Scope reads and understand result types

$task = $project->tasks()->findOrFail($taskId);
$open = $project->tasks()->where('status', 'todo')
    ->orderBy('id')->limit(20)->get();

findOrFail searches by primary key and throws if absent; get() returns a possibly empty collection. Project scoping excludes another project's tasks, but you must still authorize access to the project itself. Unpredictable IDs are not an authorization system.

4. Date casts and dirty state

Task casts due_at to immutable_datetime. A non-null value is an immutable date object: addDay() returns a new value instead of changing the model's original date. Assign it and save to persist the change. Null remains null and must be handled before invoking methods.

$task->due_at = $task->due_at?->addDay();
$task->fill(['status' => 'done']);
$changed = $task->isDirty('status');
$task->save();

The sample date assumes the skeleton's UTC application timezone. Define the timezone of user input and normalize it before persistence; a timestamp string without an offset cannot express every intention. Casting neither validates input nor chooses your product's time convention. See Eloquent casting.

5. Mass assignment does not guard every write path

Fillable governs fill/create and instance updates that use fill. Direct attribute assignment followed by save(), forceFill(), query builders and bulk updates are different paths. Do not bypass errors with forceFill($request->all()). Use validated, action-specific fields and server-controlled ownership.

Model::preventSilentlyDiscardingAttributes(true) can expose discarded fields during development. Our test restores that static setting in finally. Factories normally run unguarded, so test mass assignment with an ordinary model instead of relying only on factory tests. Consult Eloquent documentation.

6. Understand deletion scope

$task = $project->tasks()->findOrFail($taskId);
$task->delete();

Task does not use SoftDeletes yet, so this removes the actual test record. Never casually run it against data you need to keep. Deleting a task leaves its project; deleting a project containing tasks is restricted by lesson 8's foreign key. Bulk update/delete differs from per-model operations in event behavior, which matters when introducing observers.

7. Verify the lifecycle in an isolated database

Create 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

The full suite passed 24 tests with 78 assertions. Checks cover reloaded values, immutable casts, dirty state, persisted updates, task deletion without project deletion, strict rejection of project_id and scoped reads excluding another project's task. SQLite memory storage is explicit; no on-disk local data was deleted.

8. Exercise

Add a null due_at test and a title-update/fresh test. Call fill() without save() and prove the database has not changed. Explain the three separate layers: validation checks values, fillable controls bulk attribute assignment and policies check permission. None replaces the others.

Navigation: Lesson 9 · Roadmap. Next we expand and test Eloquent relationships.

Laravel 13 course navigation

Previous lesson (09) · Next lesson (11) · All 37 lessons

Discussion

Comments 0

Sign in to comment

You need an account to join the discussion and reply to other readers.

Sign inRegister

No comments yet. Be the first to share your thoughts.