Test your knowledge
Take a quiz on this chapter to see how well you understood the concepts.
Take QuizUp until now, we have built the core pieces of domain code: DTOs carry data, actions encapsulate business logic, models provide persistence, and states handle conditional behavior. The missing piece is how to test all of this.
The modular architecture we have built enables straightforward testing. Each piece is small, focused, and has clear inputs and outputs. But testing complex business logic still requires setting up realistic data, and that is where Laravel's factory system comes in.
Laravel's factory system
Laravel ships with a powerful factory system for creating model instances in tests and seeders. Factories are classes that extend Illuminate\Database\Eloquent\Factories\Factory and define a definition method with default attributes.
Here is a factory for the Course model:
namespace Database\Factories;
use Domain\Courses\Models\Course;
use Domain\Courses\States\DraftCourseState;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Course>
*/
class CourseFactory extends Factory
{
protected $model = Course::class;
public function definition(): array
{
return [
'course_code' => 'CRS-' . fake()->unique()->randomNumber(5),
'title' => fake()->sentence(3),
'status' => DraftCourseState::class,
];
}
}
Usage is clean and type-safe:
$course = Course::factory()->create();
Your IDE knows the result is a Course model. The factory handles default values. And you can override any attribute inline:
$course = Course::factory()->create([
'title' => 'Domain-Driven Design',
]);
Factory states
States define discrete modifications you can apply in any combination. For our course factory, we need states for published and draft:
class CourseFactory extends Factory
{
protected $model = Course::class;
public function definition(): array
{
return [
'course_code' => 'CRS-' . fake()->unique()->randomNumber(5),
'title' => fake()->sentence(3),
'status' => DraftCourseState::class,
];
}
public function published(): static
{
return $this->state(fn (array $attributes) => [
'status' => PublishedCourseState::class,
]);
}
public function draft(): static
{
return $this->state(fn (array $attributes) => [
'status' => DraftCourseState::class,
]);
}
public function startsAt(string|Carbon $date): static
{
return $this->state(fn (array $attributes) => [
'starts_at' => $date,
]);
}
}
State methods return a new factory instance, so they are immutable by default. You can safely derive variations without worrying about shared state between tests:
$publishedCourse = Course::factory()->published()->create();
$draftCourse = Course::factory()->create();
Factory relationships
The real power comes from composing factories with relationships. When a published course needs enrollment records, use the has method:
$course = Course::factory()
->published()
->has(Enrollment::factory()->count(3))
->create();
You can also wire up relationships automatically inside the state itself using afterCreating:
public function published(): static
{
return $this->state(fn (array $attributes) => [
'status' => PublishedCourseState::class,
])->afterCreating(function (Course $course) {
Enrollment::factory()->for($course)->create();
});
}
Now every published course automatically gets an enrollment:
$course = Course::factory()->published()->create();
// Enrollment record is created automatically
For fine-grained control, compose at the call site:
$course = Course::factory()
->published()
->has(
Enrollment::factory()
->count(3)
->for(Student::factory()->state(['full_name' => 'Jane Doe']))
)
->create();
Or test edge cases like late enrollments:
$course = Course::factory()
->startsAt('2026-01-01')
->has(
Enrollment::factory()->state(['enrolled_at' => '2026-02-15'])
)
->create();
With just a few lines, you set up complex scenarios that would be painful to create manually.
Creating multiple models
Need several models at once? Use the count method:
$courses = Course::factory()->count(5)->published()->create();
No custom times() method needed — Laravel handles it natively.
Testing DTOs
DTOs are the simplest to test: you mostly do not test them at all. Their entire purpose is strong typing, and the type system enforces correctness.
If your DTOs have static constructors that map external data, test the mapping:
it('maps from store request', function (): void {
$course = Course::factory()->create();
$dto = StudentData::fromStoreRequest(new StudentStoreRequest([
'full_name' => 'Jane Doe',
'email' => 'jane@example.com',
'course_id' => $course->id,
'enrolled_at' => '2026-03-01',
]));
expect($dto)->toBeInstanceOf(StudentData::class);
});
And test that invalid input throws exceptions:
it('throws when course does not exist', function (): void {
StudentData::fromStoreRequest(new StudentStoreRequest([
'full_name' => 'Jane Doe',
'email' => 'jane@example.com',
'enrolled_at' => '2026-03-01',
]));
})->throws(ModelNotFoundException::class);
That is it. Type checks cover everything else.
Testing actions
Actions follow a consistent three-step pattern: setup, execute, assert.
it('saves the course with correct total duration', function (): void {
// Setup
$courseData = CourseDataFactory::factory()
->addLessonDataFactory(
LessonDataFactory::factory()
->withTitle('Getting Started')
->withVideoCount(3)
->withDuration(15)
)
->addLessonDataFactory(
LessonDataFactory::factory()
->withTitle('Advanced Topics')
->withVideoCount(5)
->withDuration(45)
)
->create();
$action = app(PublishCourseAction::class);
// Execute
$course = $action->execute($courseData);
// Assert
$this->assertDatabaseHas($course->getTable(), [
'id' => $course->id,
]);
$expectedDuration = 15 + 45;
expect($course->course_code)->not->toBeNull()
->and($course->total_duration)->toBe($expectedDuration)
->and($course->lessons)->toHaveCount(2);
});
When actions compose other actions, test each one independently. The PublishCourseActionTest checks that lessons are linked to the course, but it does not test whether CreateLessonAction correctly calculates duration breakdowns. That is tested in CreateLessonActionTest.
Testing models
With business logic moved out of models, what remains to test? Query builders, collections, and event subscribers.
Query builder tests verify that scopes return the right results:
it('filters only published courses', function (): void {
$publishedCourse = Course::factory()->published()->create();
$draftCourse = Course::factory()->draft()->create();
expect(Course::query()->wherePublished()->whereKey($publishedCourse->id)->count())
->toBe(1)
->and(Course::query()->wherePublished()->whereKey($draftCourse->id)->count())
->toBe(0);
});
Collection tests verify that filtering and aggregation methods work:
it('filters only lessons with quizzes', function (): void {
$quizLesson = Lesson::factory()->withQuiz()->create();
$plainLesson = Lesson::factory()->withoutQuiz()->create();
$collection = new LessonCollection([$quizLesson, $plainLesson]);
expect($collection->withQuizzes())->toHaveCount(1)
->and($quizLesson->is($collection->withQuizzes()->first()))->toBeTrue();
});
Subscriber tests can be done without triggering the model event. Call the subscriber method directly:
it('calculates total duration on saving', function (): void {
$subscriber = app(CourseSubscriber::class);
$event = new CourseSavingEvent(
Course::factory()->create()
);
$subscriber->saving($event);
expect($event->course->total_duration)->not->toBeNull();
});
Since PHP passes objects by reference, assertions on $event->course reflect whatever the subscriber did.
The same pattern emerges everywhere: setup, execute, assert. Laravel's factory system makes the setup phase clean and readable, so you can focus on what actually matters — verifying that your business logic is correct.
Summary
- Domain-oriented architecture makes testing straightforward because each piece is small, focused, and has clear inputs and outputs.
- Laravel's factory system creates model instances with sensible defaults. Use factory states (
published(),draft()) for discrete variations and factory relationships (has(),for()) to set up complex scenarios in a few lines. - DTOs are barely tested — their purpose is strong typing, and the type system enforces correctness. Only test static constructors that map external data.
- Actions follow setup-execute-assert: create DTOs, resolve the action from the container, call
execute, and assert the output. When actions compose other actions, test each one independently. - Models are tested through their extracted pieces: query builders verify scopes return correct results, collection classes verify filtering and aggregation, and subscribers can be tested by calling their methods directly.
- The consistent setup-execute-assert pattern works everywhere. Factories handle the setup, and you focus on verifying that business logic is correct.
References
- Database Testing — Laravel Documentation
- Eloquent Factories — Laravel Documentation
- Introduction to Test Doubles in PHP — Ahmad Mayahi
- Pest Testing Framework — Pest Documentation
Test your knowledge
Take a quiz on this chapter to see how well you understood the concepts.
Take Quiz