You might not need a repository in Laravel: 3 alternatives

Mazen Touati
Studocu Tech
Published in
13 min readJan 30, 2023

The repository pattern is a bit controversial on how it is being used in Laravel projects. Different developers have different needs and motivations. Some are adhering to the textbook definitions and others are using it to dump their queries.

In this article, we will revisit the state of the art of the repository pattern with its pros and cons. Then, we will walk through three alternatives to architect our codebase around queries.

Photo by Simone Hutsch on Unsplash

After reading this article, you will:

  • Get familiar with the repository pattern, its pros, and its cons.
  • Be able to use Eloquent Scopes.
  • Be able to use Custom Queries.
  • Be able to use Actions.

So what’s a repository anyway?

In a nutshell, the repository pattern is an abstraction layer between your data storage and your business logic. It is a good application of the Dependency Inversion Principle (DIP) as we will be dealing with abstracted interfaces instead of concrete implementations (see diagram 1 below).

diagram 1: The repository pattern

The main benefits are:

  • Separation of concerns.
  • Re-usability.
  • Caching accessed data.
  • Swappability.
  • Testability.

Implementation

To use a repository pattern, you will need to have interfaces and a concrete implementation for each storage layer. The following is an example of such implementation,

diagram 2: Repository pattern Implementation example

Note: in the above example, we have a separate repository for accessing data through cache (BookCacheRepository). That’s one option, the other option is that you cache the results directly within the main repository. More on this later.

An example in Laravel,

// First we bind the interfaces to the implementation.
class AppServiceProvider extends ServiceProvider
// ...

public function register(): void
{
$this->app->bind(BookRepositoryInterface::class, BookEloquentRepostiory::class);
$this->app->bind(ProductRepositoryInterface::class, ProductThirdPartyRepostiory::class);
}
}

// Then we can use it in our Business logic using dependency injection.
class FooController extends Controller
{

public function __invoke(BookRepositoryInterface $repostiory)
{
// ...
$data = $repostiory->get();
}
}

In the above example the binding is hardcoded. Depending on the use case, the binding could be dynamic. Like binding to a specific repository based on whether the request is a web request or an api call.

Main benefits

In this section, I’ll detail two points that make the pattern distinguished: Swappability and Testability. The remaining points speak for themselves.

Swappability

Thanks to DIP, the business logic is indifferent to the implementation. Thus, we can easily swap the implementation details without changing the business logic.

Let’s imagine you’re running a shop and one day you decided to externalize your inventory management. The third-party service provides an API to communicate with it. Let’s assume your business logic is bound to ProductRepositoryInterface. With that in mind, you will only need to create a new implementation that interacts with the API instead of the Database. Then you will bind the new implementation to the said interface in the container. Everything will still work as expected.

It sounds great, is there a catch?

While researching this article, I came across many examples of the repository pattern in Laravel. Most of them (if not all) are coupled with Eloquent and its Collections. Which makes it impossible to swap it with a completely different data storage or ORM.

Therefore, to make the swap possible you should not couple your interfaces to Eloquent jargon. When using a different data layer, most likely you’re not using Eloquent anymore.

You might argue that for the above API example, you can still hydrate the results into Eloquent models. That’s a risky workaround because Eloquent is an Active Record Pattern implementation. It represents a relational database record while being able to update and persist it.

Takeaway/TL;DR

Swapping the implementation is a lucrative concept. It sounds very beneficial, yet, to do it right you should not couple it with Eloquent. Except for, using the repository pattern only to separate the data and cache layers (see diagram 2 above) or to have mocks/fakes (see next section).

Are you confident that one day you will swap the implementation? If the answer is yes, then you can go for it. Otherwise, you can stick with YAGNI, rarely, you will ever need to have something other than Eloquent in your Laravel project.

Testability

Thanks to the swappability, we can have different implementations for tests where we don’t interact with a real database (a.k.a test mocks/fakes).

The main arguments for faking the database implementation are:

  • Fast tests
  • Less prone to random failure tests.

Some might go for an in-memory database like SQLite. I wanted to mention this here, as the arguments and concepts are the same hence the downsides.

The catch is, it is risky and produces inconclusive tests. The tests won’t be running against the same database that runs your production server.

You’re not asserting the database works as expected in different scenarios. Your production might break after deploying an incompatible database change. Or, due to the differences in the default behavior for each DBMS, you might have system in-correctness.

How can you make your tests fast/resilient without faking?

Laravel provides a RefreshDatabase trait, which makes testing with real databases more convenient. You will be dealing with a small dataset and a clean state each time.

Takeaway/TL;DR

It is safer, although slower, to run your tests against an actual mirror of your production database. Avoid swaps, mocks, and different databases during tests. Test against what your production is using.

Why the Repository Pattern Can’t Keep Up?

You might get away with the repository pattern in simple projects. However, the more business logic you add, the more complex these repositories get.

Let’s have an example,

We have a book repository with a method all to fetch all books. At some point, you added premium books and premium membership. Now, you want all to return all books for premium users but only the non-premium books for unpaid users.

How would you solve this? Here are some suggestions:

  • Add an extra parameter (like the user object or a flag) to all to filter the books.
  • Add an extra method for unpaid users, like allWithoutPremium.
  • Add a separate repository for premium users.

Later, we introduced two new features: anonymous books and roles. Only users with an administrator role should see the authors. We want to conditionally load this relation.

What changes will you introduce to your repository?

Did you see the problem here with a growing project? Repositories don’t usually scale well. They will easily collect code smells as the logic becomes complicated. You will end up with massive repositories in terms of size and cognitive load.

In the next part, we will explore together three alternatives to overcome these issues and make our codebase more readable and maintainable.

Repository pattern alternatives

In this part, we will go through alternative ways to abstract the queries from the business logic. Thus, having readable, maintainable, and testable code. The alternatives are not a 1:1 replacement for the repository pattern. However, they are ways to encapsulate away data access and manipulation.

Eloquent Scopes

As it is being assumed that you’re going to stick with Eloquent as an ORM then let’s keep the code DRY with scopes.

Scopes are the most primitive/low-level alternative we have on the list. Basically, a scope will allow you to factorize common constraints for your models.

Let’s follow up with the earlier example, we have books fetched and used all over the place. We want to conditionally load either free books only or all books including premium. It will be based on the logged-in user.

Using scopes for the book states in this case is a great idea. Here’s an example of how it might look like,

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Book extends Model
{
public function scopeWhereFree(Builder $query): void
{
$query->where('is_premium', false);
}


public function scopeWherePublished(Builder $query): void
{
$query->whereNotNull('published_at');
}
}

you can use the above scope as the following in your code,

<?php

use App\Models\Book;

// ...

Book::query()
->wherePublished()
->when(!Auth::user()?->isPremium(), fn(Builder $query) => $query->whereFree())
->latest()
->limit(10)
->get();

The above query will load all books by default, however, when the user is not logged in or not premium, we will limit the results to the free books only.

When using scopes, you create one place that holds the criteria. If tomorrow you want to change what would be considered a free or a published book you only need to change the scope.

Scopes set the foundation for the next options, as they serve as a great building block. They can also be used within repositories.

Bonus Tip: you might also consider using Custom Query Builders in Laravel to further expand your model’s query builder and slim down your models.

Custom Queries

A custom query is a way to define complex constraints for fetching data while providing a business context. You can think of it as a complex method in a repository, however, these would be standalone classes.

You can have a line between a custom query and a scope. The moment you start thinking about limiting data, ordering, caching, joins, or any other custom logic you have to use a custom query instead.

Let’s have an example

We want to fetch a set of premium books for a particular category. We should only fetch a pre-defined number of books and order them by date from most recent to least recent.

When the category is archived, we should only fetch books without users (it does not make a lot of sense, but it is just for the sake of the example).

With that in mind, we can have the following custom query,

<?php

namespace App\Queries\Books;

class LatestPremiumBooksOfCategoryQuery
{
public function __constructor(
private readonly Category $category,
private readonly ?int $limit = null,
): void
{
$this->limit ??= config('categories.limit');
}

/**
* @return Collection<Book>
*/
public function get(): Collection
{
return $this
->category
->books()
->wherePublished()
->wherePremium()
->when($this->category->isArchived(), fn(Builder $builder) => $builder->whereNull('books.user_id'))
->latest()
->take($this->limit)
->get();
}


// usage

(new LatestPremiumBooksOfCategoryQuery($category))->get();

Note: In the above example, we only have one public method to get all results. You can add as many public access points as needed, for example, paginate() to get paginated data. You can factorize the common part into a separate private method like query()

Caching strategy

In the above query, we didn’t have any caching. However, it would be up to you how to cache the results.

We can cache the results in two ways: Cache the entire data or Cache the ids only*.

* The idea is to cache the ids, and the next time, you will fetch the data using where in, which will perform faster than the original filters (when finding the ids in the first place). Similar to Laravel’s SerializesModels for jobs.

Pros and cons

Custom Query caching strategies pros and cons
Custom Query caching strategies pros and cons

The pros and cons of each strategy almost contrast each other. Thus, you might use a combination of both as well. It depends on what’s being cached and how it is being used.

Rules

For consistency, you will need to have a set of rules on how to use them in your organization.

At Studocu, we are using the following set of rules,

Do

  • Eager load relationships.
  • Define Select / Where / Joins / OrderBy / Limit on queries.
  • Deal with Cache.
  • Expose all use-case access-point methods like get or first methods (but never expose the builder).

Don’t

  • Deal with the presentation layer (returning JsonResource for example).
  • Execute queries other than SELECT.

Testing

The custom query’s goal is to fetch the data you need accurately. Thus it is important to assert it is doing it properly.

You need to make sure the test is as conclusive as possible. For example, to test the above query, you need to ensure the following:

  • Seeding books for the target category with and without eligibility criteria.
  • Seeding books for other categories with and without eligibility criteria.
  • Set predictable order for the books.

This way, you will ensure the query is selective about the data it gets.

In general, these are some of the things you need to assert when testing your queries:

  • Filtering by FKs.
  • Filtering by states.
  • Filtering by thresholds.
  • Ordering.
  • Limiting.

The following is an example for testing the above custom query,

<?php

class LatestPremiumBooksOfCategoryQueryTest extends TestCase
{
use RefreshDatabase;
use WithFaker;

private const LIMIT = 5;

/** @test */
public function it_works_for_non_archived_categories(): void
{
// Arrange

$categories = Category::factory()->count(4)->create()->shuffle();

$targetCategory = $categories->pop();

$expectedBooks = $this->seedBooks($targetCategory);

$categories
->each(function (Category $category) {
// Seed books that should not be included.
$this->seedBooks($category);
});

// Act

$result = (new LatestPremiumBooksOfCategoryQuery($targetCategory, self::LIMIT))->get();

// Assert

$this->assertCount(self::LIMIT, $result);

$this->assertEquals(
$expectedBooks->toArray(),
$result->toArray(),
);
}
}

You can inspect the full test here.

Bonus Tip: you can further encapsulate your queries using ViewModels. Spatie has a ViewModels package that will make the implementation seamless.

Takeaways

The custom query classes will help hide all the builder chaining while providing the possibility to adapt to many use cases like caching the results (with optional serialization), re-using results from other queries (including custom queries), early exits, dynamic empty states, custom eager loading, accepting query parameters and acting upon them, etc.

You can check a non-working complex example of a Custom Query Class here where it showcases what you can do inside a custom query class.

Actions

An action is a class that performs a domain-specific task. It ranges from simple operations like creating, updating, or deleting data to performing complex logic like merging two models and migrating all related data from one to another.

You can think of them as the CUD part of a repository (which is CRUD) plus the business logic of a service.

Actions are highly reusable across different applications and composable within each other.

Implementation

There’s no standardized practice for Actions in Laravel. You can come across different implementations. Yet, they are all orbiting around the same concept. It is a good idea to regulate the implementation details in your organization.

At Studocu, we use the following rules:

  1. Actions must not be extended nor extend. We will rely on composition instead. Note: You can enforce using the final keyword instead, however, it won’t allow you to mock the actions during testing. Unless you are doing black-box testing which implies you’re not testing the actions in isolation.
  2. Action classes must use dependency injection in the constructor to resolve the dependencies. Hence, they should be injected as well.
  3. Action classes must have only one public method, called execute. The parameters and return of that method changes per class. All other methods and properties should be declared private.
  4. The execute method can take a max of 1 parameter. If you need more, use a DTO. Note: DTOs will help make the action reusable. They will be constructed using factories based on the context in which they are being used.

Actions in Action

Let’s create an action to publish our books. We want to be able to do the following:

  • Save who did the operation.
  • Make the book premium when requested.
  • Dispatch a “book published” event. So event listeners can run. For example, to send emails to users that we have a new book available.

With that in mind, we can create the following action,

<?php

class PublishBookAction
{
public function execute(PublishBookData $data): void
{
$data->book->markAsPublishedBy($data->user);

if ($data->shouldBePremium) {
$data->book->markAsPremium();
}

$data->book->save();

event(new BookPublishedEvent($data->book));
}
}
<?php

final class PublishBookData
{
public function __construct(
public readonly Book $book,
public readonly User $user,
public readonly bool $shouldBePremium,
) {
}

public static function fromBookPublishRequest(PublishBookStoreRequest $request): self
{
$data = $request->validated();

$book = Book::findOrFail($request->route('book_id'));

return new self(
book: $book,
user: $request->user(),
shouldBePremium: $data['should_be_premium'] ?? false,
);
}
}

Takeaways

In the above example, we used an action to publish a book, while adhering to the business rules. We’ve transported the data using Action-specific DTOs.

The latter handles querying and initializing all the required input data for the action. We used static factories to make instantiating them from different contexts easy.

You can read more about actions, different use cases and how they can be composed in this article by Brent.

Testing

You might test actions in isolation or as part of a black-box feature test, or both of them together. It is up to you and your organization how you approach tests.

Black-box testing is helpful if you’re using TDD and it will make refactoring a lot easier. Your tests will serve as a documentation and assertion gate for your features.

The following is an example of how to black-box test the book publishing endpoint,

class PublishBookTest extends TestCase
{
use RefreshDatabase;

/**
* @dataProvider publishBookDataProvider
* @test
*/
public function it_publishes_books(array $requestParams, bool $expectedToBePremium): void
{
// Arrange

Event::fake();

$book = Book::factory()->free()->unpublished()->create();

$user = User::factory()->create();

Carbon::setTestNow(today());

// Act

$response = $this
->actingAs($user)
->postJson(route('books.publish', ['book_id' => $book->id, ...$requestParams]));

// Assert

$response->assertOk();

$book->refresh();

$this->assertEquals($expectedToBePremium, $book->isPremium());

$this->assertEquals($user->id, $book->published_by);

$this->assertEquals(today(), $book->published_at);

Event::assertDispatched(
BookPublishedEvent::class,
fn (BookPublishedEvent $event) => $event->book->is($book),
);
}

public function publishBookDataProvider(): array
{
return [
'it publish book as free book [explicit]' => [
'params' => [
'should_be_premium' => false,
],
'expectedToBePremium' => false,
],
'it publish book as free book [implicit]' => [
'params' => [],
'expectedToBePremium' => false,
],
'it publish book as premium book' => [
'params' => [
'should_be_premium' => true,
],
'expectedToBePremium' => true,
],
];
}
}

In the above example, only one test has been included to not over-charge the article. You can inspect the full test here.

Closing notes

We revisited the repository pattern with its pros and cons. Mainly the swappability which allows externalizing parts of your app, separating data and cache layers, or making tests run faster.

Then, we saw three alternatives that together can abstract your data management:

  • Laravel scopes to supercharge Eloquent with business-related queries.
  • Custom Queries to fetch and cache your complex data.
  • Actions to manipulate your data.

In the end, I want to highlight that these alternatives are not what you should use, rather, they are what you can use. I hope this was insightful, let me know what you think in the comments.

You could check this GitHub repository for all the examples mentioned above.

Thanks for reading.

--

--