Save Your Tests with Repositories in Laravel
Tired of slow, brittle tests in Laravel? The repository pattern can help! Learn how to decouple your tests from the database, speed up execution, and write cleaner, more maintainable code. 🚀
Laravel simplifies database interaction with Eloquent, but as applications expand, using Eloquent models directly in controllers and services can become problematic.
Writing tests can quickly become frustrating when your code tightly couples business logic with Eloquent models. Directly interacting with the database in every test slows things down, increases fragility, and makes unit tests feel more like integration tests.
The repository pattern offers an abstraction layer that separates the concerns of data access from the business logic, helping to manage these issues effectively.s business logic from data access, making the codebase cleaner, more flexible, and easier to test. In this post, we’ll explore how repositories can help you write better tests, improve code organization, and make your Laravel applications more scalable.
What are repositories?
A repository is the layer between your data store (e.g. a Postgres instance) and your applications business logic. Instead of directly mutating the database within a controller or service class, you defer data management to the repository.
Think of it as a middleman:
- Your application asks the repository for some data (e.g. an article)
- The repository fetches the article from a source. This can be a database, an API, or an S3 bucket.
- Your application gets the data without worrying about what provided it
This approach makes your code more organized, easier to test, and flexible enough to switch databases or data sources without changing your entire application.
The Problem: Direct Eloquent Usage Can Lead to Issues
When Eloquent models are used directly throughout applications, several issues arise:
- Tightly-coupled code – If you query models directly in controllers, services, or other parts of your app, switching to a different data source (e.g., an external API) becomes difficult.
- Hard to maintain code – Business logic often gets mixed with database queries, making refactoring and debugging more challenging.
- Difficult testing – Unit tests should ideally run without relying on a database, but direct Eloquent queries make this nearly impossible. Tests become slower and prone to failures due to database state.
How Repositories Solve These Problems
Let's go back to that middleman approach earlier. A repository sits between your applications business logic and the data layer of your app. This approach has several benefits:
✅ Separation of Concerns – Controllers and services don’t need to know how data is stored; they only interact with the repository interface.
✅ Easier Testing – Instead of hitting the database, repositories can be mocked in tests, leading to faster and more reliable unit tests.
✅ Better Maintainability – If the way you store data changes (e.g., switching from MySQL to PostgreSQL or adding a caching layer), you only need to update the repository instead of refactoring your entire codebase.
✅ Improved Reusability – The same repository can be used in multiple places, ensuring consistency and reducing code duplication.
By adopting the repository pattern, Laravel developers can create applications that are more structured, easier to scale, and much simpler to test. In the next sections, we’ll dive into implementing repositories and how they can transform your testing workflow. 🚀
How Repositories Save Your Tests: A Real-World Example
Let’s say you’re building a podcast platform where users can upload episodes. When a new podcast episode is uploaded, it enters a pending state and gets processed in the background by a queued job.
Without a repository, your job might look something like this:
final class ProcessPodcastJob implements ShouldQueue{ public function __construct(private int $podcastId) {} public function handle(): void { $podcast = Podcast::find($this->podcastId); if (! $podcast || $podcast->status !== 'pending') { return; } // Simulate podcast processing $podcast->status = 'processed'; $podcast->save(); }}
Now lets write a test for it:
it('processes a pending podcast', function () { $podcast = Podcast::factory()->create(); $job = new ProcessPodcastJob($podcast->id); $job->handle(); expect($podcast->refresh()->status)->toBe('processed');});
In the above example, we're hitting the database 4 times! The first time, to create the podcast record. The second time, inside the job, to fetch that model. The third time, also inside the job, to update the model and the fourth time to refresh the model in the test to get the updated status.
Not lets introduce repositories 😎
First, we create a repository interface to abstract database operations:
interface PodcastRepositoryInterface{ public function findPendingById(int $id): ?Podcast; public function markAsProcessed(Podcast $podcast): void;}
We then create two implementations, one for eloquent:
final class EloquentPodcastRepository implements PodcastRepositoryInterface{ public function findPendingById(int $id): ?Podcast { return Podcast::where('id', $id)->where('status', 'pending')->first(); } public function markAsProcessed(Podcast $podcast): void { $podcast->update(['status' => 'processed']); }}
and another "in-memory" variant:
class InMemoryPodcastRepository implements PodcastRepositoryInterface{ public function __construct(private array $podcasts) {} public function findPendingById(int $id): ?Podcast { return collect($this->podcasts) ->first(fn (Podcast $podcast) => $podcast->id === $id && $podcast->status === 'pending'); } public function markAsProcessed(Podcast $podcast): void { $index = collect($this->podcasts) ->search(fn (Podcast $other) => $podcast->id === $other->id); $podcast->status = 'processed'; $this->podcasts[$index] = $podcast; } public function assertProcessed(int $id): void { $podcast = collect($this->podcasts) ->first(fn (Podcast $other) => $podcast->id === $other->id); \PHPUnit\Framework\Assert::assertTrue($podcast?->status === 'processed'); }}
Now, refactor the job to depend on the repository instead of Eloquent:
final class ProcessPodcastJob implements ShouldQueue{ public function __construct(private int $podcastId) {} public function handle(PodcastRepositoryInterface $repository): void { $podcast = $repository->findPendingById($this->podcastId); if (! $podcast) { return; } // Simulate podcast processing $repository->markAsProcessed($podcast); }}
Finally, to ensure our production code works as expected, update we'll bind the eloquent implementation to the interface in the register
method of our AppServiceProvider
:
public function register(): void{ $this->app->bind(PodcastRepositoryInterface::class, EloquentPodcastRepository::class);}
Now let's update our test:
it('processes a pending podcast', function () { // Arrange $repository = new InMemoryPodcastRepository([ $podcast = Podcast::factory()->make(), ]); // Act $job = new ProcessPodcastJob($podcast->id); $job->handle($repository); // Assert $repository->assertProcessed($podcast);});
In this test, we're no longer hittin the database at all 🤘. Yes it's more boiler plate code to write, but we've now made our app code more awesome.
Why Should You Use the Repository Pattern?
The repository pattern isn’t just about adding an extra layer to your Laravel application—it’s about making your code more testable, maintainable, and scalable.
✅ Easier Testing – By decoupling your business logic from Eloquent, you can mock repositories in tests, making them faster, more focused, and independent of the database.
✅ Clearer Unit Tests Focused on Business Logic – Unit tests should test what your code does, not how the database behaves. By using repositories, your tests focus on logic—like processing a podcast—without worrying about database state or migrations. This results in fewer flaky tests and less setup overhead.
✅ Better Code Organization – Repositories separate data access from business logic, keeping controllers and services clean. This makes refactoring and debugging much easier.
✅ More Flexibility – Need to switch from MySQL to PostgreSQL? Add caching? Retrieve data from an API instead of a database? With repositories, these changes happen in one place instead of across your entire codebase.
✅ Improved Maintainability – When data access is centralized in a repository, updating logic doesn’t require digging through controllers, jobs, and services—just update the repository.
While repositories might not be necessary for small Laravel apps, they shine in larger applications where structure, testability, and maintainability matter. If you want faster tests, cleaner code, and an architecture that scales, repositories are worth adding to your toolkit. 🚀
This small change can drastically improve the reliability and speed of your Laravel test suites, which is paramount to large scale applications.
Happy coding 🚀