Lesson 9 — Seeders and Factories: create practice data for the TaskFlow schema from lesson 8. We want valid relationships, tests that do not depend on chance and a demo seeder that can rerun without duplicating records whose lookup keys remain unchanged.
1. Distinguish factories from seeders
A factory defines sample model construction; a seeder coordinates initial data for an environment. Tests usually need minimal records with explicit decision-making attributes. A demo benefits from stable names shared by every learner. Neither purpose requires customer data or a production database dump.
make() returns an unsaved model while create() persists it; nested factory relationships can create related records, so do not infer every side effect from the method name alone. Assert record counts and relationships where they matter. Refer to factories and seeding.
2. Add minimal models
Create these files. HasFactory uses naming conventions to locate factories. The owner, project and tasks relationships support for() and related creation. Ownership and assignment IDs are deliberately absent from fillable; server-side logic will assign them rather than accepting an entire form payload.
app/Models/Project.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Project extends Model
{
use HasFactory;
protected $fillable = ['name', 'slug'];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
public function tasks(): HasMany
{
return $this->hasMany(Task::class);
}
}app/Models/Task.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Task extends Model
{
use HasFactory;
protected $fillable = ['title', 'description', 'status', 'priority', 'due_at'];
protected function casts(): array
{
return ['due_at' => 'immutable_datetime'];
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assignee_id');
}
}These models enable factories now; later lessons examine CRUD, casts and relationships in depth. Fillable is neither authorization nor validation. Factory/seeding workflows can bypass mass-assignment protection, so a passing factory test does not prove an HTTP write endpoint is safe.
3. Define predictable defaults
Keep the skeleton's UserFactory and add:
database/factories/ProjectFactory.php
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class ProjectFactory extends Factory
{
public function definition(): array
{
return [
'owner_id' => User::factory(),
'name' => fake()->words(3, true),
'slug' => 'project-'.Str::uuid(),
];
}
}database/factories/TaskFactory.php
<?php
namespace Database\Factories;
use App\Models\Project;
use Illuminate\Database\Eloquent\Factories\Factory;
class TaskFactory extends Factory
{
public function definition(): array
{
return [
'project_id' => Project::factory(),
'assignee_id' => null,
'title' => fake()->sentence(5),
'description' => null,
'status' => 'todo',
'priority' => 'normal',
'due_at' => null,
];
}
public function done(): static
{
return $this->state(fn () => ['status' => 'done']);
}
}TaskFactory creates a project when none is supplied, and that project creates an owner. To share one project, use Task::factory()->count(3)->for($project)->done()->create(). Omitting for() may create a different number of projects than intended. The done() state communicates purpose without scattering status strings across tests.
Use Faker for attributes unrelated to the assertion. Supply fixed titles for sorting tests and freeze time with explicit due_at values for deadline tests. UUID slugs reduce accidental factory collisions but do not make random fixtures byte-for-byte reproducible.
4. Seed a controlled demo
database/seeders/TaskFlowDemoSeeder.php
<?php
namespace Database\Seeders;
use App\Models\Project;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class TaskFlowDemoSeeder extends Seeder
{
public function run(): void
{
if (! app()->environment(['local', 'testing'])) {
throw new RuntimeException('Demo seeding is restricted to local/testing.');
}
DB::transaction(function () {
$owner = User::firstOrCreate(['email' => 'taskflow-demo@example.test'], [
'name' => 'TaskFlow Demo',
'password' => Str::random(48),
]);
$project = Project::where('owner_id', $owner->id)->where('slug', 'course-demo')->first();
if (! $project) {
$project = new Project(['name' => 'Course Demo', 'slug' => 'course-demo']);
$project->owner()->associate($owner);
$project->save();
}
foreach (['Read the brief', 'Write a test', 'Review the change'] as $title) {
$project->tasks()->firstOrCreate(['title' => $title], ['status' => 'todo', 'priority' => 'normal']);
}
});
}
}Replace DatabaseSeeder's default Test User creation with $this->call(TaskFlowDemoSeeder::class);. After confirming the connection, run only against a dedicated local database:
php artisan migrate
php artisan db:seed --class=TaskFlowDemoSeeder
php artisan db:seed --class=TaskFlowDemoSeeder
The lookup keys are demo email, project slug and task title. firstOrCreate preserves edited task status while the title remains unchanged. Rename a demo task and rerunning creates the old title again: this is not general synchronization or universal idempotency. Avoid concurrent seeding; without a project_id/title unique constraint, task creation is not race-proof.
The demo owner receives a random password that is never printed; the skeleton User model hashes it through its cast. UserFactory has a predictable test password and belongs only in local/test use. The environment guard is extra protection, not a substitute for checking connections: a misconfigured local environment can still point at the wrong database.
5. Verify relationships, reruns and environment restrictions
tests/Feature/TaskFlowFactoryTest.php
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Task;
use Database\Seeders\TaskFlowDemoSeeder;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Tests\TestCase;
class TaskFlowFactoryTest 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_factory_reuses_the_explicit_project_and_applies_state(): void
{
$project = Project::factory()->create();
$tasks = Task::factory()->count(3)->for($project)->done()->create();
$this->assertDatabaseCount('projects', 1);
$this->assertDatabaseCount('tasks', 3);
$this->assertTrue($tasks->every(fn ($task) => $task->project_id === $project->id && $task->status === 'done'));
}
public function test_demo_seeder_can_run_twice_without_duplicates_or_resetting_edits(): void
{
$this->seed(TaskFlowDemoSeeder::class);
Task::where('title', 'Read the brief')->update(['status' => 'done']);
$this->seed(TaskFlowDemoSeeder::class);
$this->assertDatabaseCount('users', 1);
$this->assertDatabaseCount('projects', 1);
$this->assertDatabaseCount('tasks', 3);
$this->assertDatabaseHas('tasks', ['title' => 'Read the brief', 'status' => 'done']);
}
public function test_demo_seeder_rejects_production(): void
{
$this->app->instance('env', 'production');
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Demo seeding is restricted');
$this->app->make(TaskFlowDemoSeeder::class)->run();
}
}The tests select in-memory SQLite before migrating. The production guard test changes only the environment label and invokes run() directly; it never connects to production. Other tests verify one shared project, the done state, stable counts after two seed runs and preservation of edited status.
php artisan test --filter=TaskFlowFactoryTest
php artisan test
The complete suite passed 21 tests with 64 assertions. Demo records were created only in disposable test databases; the on-disk local database has not been seeded at this milestone. The lesson 7 preview still does not persist tasks: adding models and factories does not automatically change HTTP behavior.
6. Exercise
Create one project with three todo tasks and two done tasks, then assert each count scoped by project_id. Remove for($project) and observe the side effects. Rename a demo task, reseed and explain the increased count. Do not solve the exercise by truncating tables containing data you need.
Navigation: Lesson 8 · Roadmap. Next comes Eloquent CRUD, casts and mass assignment.




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