Lesson 11 — Eloquent Relationships: connect TaskFlow's schema to querying and relationship changes. Following the CRUD lesson, we already have Project, Task and User. We add missing inverse relationships, verify assignment behavior and investigate the mistaken assumption that a loaded collection automatically follows database changes.
1. Follow the foreign keys
| Direction | Relationship | Foreign key |
|---|---|---|
| Project → owner | belongsTo | projects.owner_id |
| User → owned projects | hasMany | projects.owner_id |
| Project → tasks | hasMany | tasks.project_id |
| Task → project | belongsTo | tasks.project_id |
| Task → assignee | nullable belongsTo | tasks.assignee_id |
| User → assigned tasks | hasMany | tasks.assignee_id |
The model holding the foreign key defines belongsTo. Owner and assignee use explicit key names rather than user_id. PHP relationships do not create database constraints; lesson 8's migrations enforce referential integrity.
2. Add User's inverse relationships
Add the HasMany import and these methods to app/Models/User.php, preserving its existing casts, attributes and traits:
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 and Task already contain the other methods from lesson 9. ownedProjects means ownership; assignedTasks means current assignment. Assignment does not automatically mean project membership or permission to delete tasks. No membership table exists at this milestone.
3. Assign and unassign a user
$task->assignee()->associate($assignee);
$task->save();
$task->assignee()->dissociate();
$task->save();
associate changes the model's relationship; save persists its foreign key. dissociate clears the key, requiring a nullable column. Neither operation deletes the user. Do not delete the assignee model when the intended operation is merely unassignment.
A real application must authorize the actor, validate the assignee's eligibility and enforce task-state rules. Foreign keys establish existence, not permission. Successfully saving an arbitrary assignee_id from a form is not proof of authorization.
4. Distinguish relationship methods and properties
$project->tasks()->where('status', 'todo')->count();
$project->tasks;
$project->load('tasks');
The first line queries and counts in the database. The property returns a related collection, potentially lazy-loading it once and retaining it on the instance. The last line reloads the relationship. A previously loaded empty collection does not automatically notice a newly inserted task.
This is PHP object state, not necessarily Redis caching or a transaction bug. loadMissing() is useful for unloaded relationships, not a forced refresh of an already loaded collection. The next lesson measures queries and chooses loading behavior explicitly.
5. Deleting an assignee differs from deleting an owner
assignee_id uses nullOnDelete so tasks survive the assignee's removal. Project owner_id restricts deletion. A user who is both an assignee and a project owner may still be blocked by ownership; our deletion test deliberately uses an assignee who owns no project.
Do not enable cascade everywhere just to make deletion tests pass. Deletion policy follows retention requirements and ownership-transfer workflows. Database foreign-key actions also do not imply that each affected Task observer runs as if the application saved every model individually.
6. Verify relationships in isolation
Create tests/Feature/TaskFlowRelationshipsTest.php. The connection is explicitly in-memory before 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
The complete suite passed 27 tests with 91 assertions. Checks cover inverse ownership, task project identity, assignment/unassignment, null-on-delete and fresh queries versus stale collections. These results apply to SQLite tests, not authorization policies or every production database engine.
7. When would many-to-many be appropriate?
If a project has many members and users join many projects, design a project_user table with unique(project_id, user_id), foreign keys and role data where required. Membership is distinct from a single owner or task assignee. Agree on membership semantics before adding a pivot simply to demonstrate another relationship type.
Likewise, polymorphic relationships are not automatically preferable whenever several model types exist. Consider referential integrity, querying and type changes. The relationship documentation provides further options after this baseline is understood.
8. Exercise
Create two projects sharing an owner, count ownedProjects and verify each project's task scope. Load tasks, insert another task and compare counts before and after load(). In a disposable test, attempt to delete a user who both owns a project and has assigned tasks, then explain the blocking constraint.
Navigation: Lesson 10 · Roadmap. Next we measure N+1 and apply eager loading.




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