Advanced Testing: Integration Tests in Laravel
Master integration testing in Laravel. Learn how to manage database state, verify multi-component workflows, and ensure your application logic works in unison.
Previously in this course, we explored Testing Events and Jobs in Laravel to ensure our asynchronous processes were firing correctly. Now, we're zooming out to the "Integration" level, where we verify that our services, repositories, and database constraints actually play nice together.
When you unit test, you mock everything. But in a real-world Laravel application, your code lives and dies by its side effects: a row created in a table, a relationship established between models, and a state change that ripples across your system. Integration testing is about ensuring these connections aren't just theoretically correct, but practically functional.
Why Integration Testing Matters
In our project board application, a "Task Creation" workflow involves the TaskService, the TaskRepository, and several database-level constraints (like project ownership). If we only mock the repository, we might miss a scenario where a database constraint violation—like a foreign key mismatch—breaks the user experience.
Integration testing allows us to:
- Verify Database Persistence: Ensure data is actually saved correctly.
- Test Eloquent Relationships: Confirm that
task->projectreturns the expected instance. - Validate Business Workflows: Ensure that the sequence of operations (e.g., creating a task, assigning it to a user, and updating the project's task count) happens atomically.
Managing Database State
The biggest pain point in integration testing is database pollution. You don't want tests to leak state into each other. Laravel handles this natively using the Illuminate\Foundation\Testing\RefreshDatabase trait.
When you include this trait in your test class, Laravel performs two steps:
- It migrates your database before the test suite runs (or on the first test).
- It wraps every individual test in a database transaction, rolling it back immediately after the test completes.
A Concrete Worked Example
Let’s test our TaskService in a real integration scenario. We want to verify that when a task is created, it is correctly associated with the project and that our business logic (like setting a default status) is applied.
PHPnamespace Tests\Integration; use Tests\TestCase; use App\Models\Project; use App\Models\User; use App\Services\TaskService; use Illuminate\Foundation\Testing\RefreshDatabase; class TaskWorkflowTest extends TestCase { use RefreshDatabase; public function test_task_creation_workflow_persists_correctly() { #6A9955">// 1. Setup: Use factories to prepare the environment $user = User::factory()->create(); $project = Project::factory()->for($user)->create(); $service = app(TaskService::class); #6A9955">// 2. Action: Execute the service method $task = $service->createTask($project, [ 'title' => 'Complete documentation', 'description' => 'Write the API guide' ]); #6A9955">// 3. Assertion: Verify the database state directly $this->assertDatabaseHas('tasks', [ 'id' => $task->id, 'project_id' => $project->id, 'status' => 'pending' #6A9955">// Our default business logic ]); $this->assertEquals(1, $project->tasks()->count()); } }
By using assertDatabaseHas, we aren't just checking if the object exists in memory; we are querying the database to ensure the persistence layer captured the data exactly as intended.
Hands-on Exercise
Using the project board we’ve been building:
- Create a new test file:
php artisan make:test Integration/ProjectAssignmentTest. - Use the
RefreshDatabasetrait. - Write a test that:
- Creates a
Projectand twoUsers. - Uses your
ProjectServiceto assign the second user to the project. - Asserts that the
project_userpivot table contains the correct record.
- Creates a
- Run the test with
php artisan test.
Common Pitfalls
Even senior engineers run into these issues when writing integration tests:
- Over-mocking: If you find yourself using
MockeryorEvent::fake()inside an integration test, ask yourself if you're still testing the integration. Keep integration tests focused on the real code interaction. Reference Mocking Services and Repositories in Laravel Tests to understand when to shift from mocking to true integration. - Seeding Bloat: Avoid using large seeders in your
setUp()method. Use Mastering Laravel Database Factories and Seeding for Testing to create minimal, specific data for the test at hand. - Ignoring Transactional Boundaries: If your service uses
DB::transaction, make sure your test doesn't accidentally commit data that breaks subsequent tests. Laravel’sRefreshDatabaseis generally smart enough, but complex manual transactions can sometimes interfere with the test runner's rollback mechanism.
Recap
Integration testing bridges the gap between isolated logic and the real-world behavior of your application. By leveraging the RefreshDatabase trait and focusing on database assertions, you ensure that your services, repositories, and database schema work together as a cohesive system. This level of testing is your best defense against regression in complex workflows.
Up next: We will dive into testing API authentication to ensure our protected endpoints remain secure.
Work with me

FilamentPHP Admin Panel & Dashboard Development
A powerful admin panel for your Laravel app — built with FilamentPHP so you can manage everything without touching the database.

Laravel REST API Development
Clean, secure, well-documented Laravel REST APIs — the backend engine for your app, mobile client, or SaaS. Built by an API specialist.