DEV Community

A0mineTV
A0mineTV

Posted on

Structuring a Laravel Project with Repositories and Services (Without Over-Engineering)

A while ago, I wrote about using repositories and services in Laravel.

At the time, the goal was simple: keep controllers cleaner and move logic to dedicated layers.

I still agree with that idea.

But with more experience, I would structure that article differently today.

Because in Laravel, there is always a risk when talking about architecture:

we can improve code organization, but we can also over-engineer very quickly.

Laravel already gives us a lot out of the box:

  • Eloquent
  • Form Requests
  • API Resources
  • Route model binding
  • Events and jobs
  • A powerful service container

So the real question is not:

“Should I always use repositories and services?”

It is:

“At what point do these layers actually make my code easier to maintain ?”

That is the angle of this rewritten article.

I want to show a more practical version of the repository + service approach:

  • controllers for HTTP concerns
  • services for business rules and orchestration
  • repositories for data access only when that logic becomes worth isolating

Why I would revisit the original approach

A lot of repository pattern tutorials in Laravel follow the same structure:

  • a generic BaseRepository
  • CRUD methods like find, create, update, delete
  • a service that mostly forwards calls to the repository
  • a controller that becomes a little cleaner

At first glance, that seems fine.

But after building more real-world features, I think there is one important nuance:

if a repository is only a thin wrapper around Eloquent, it often adds indirection more than value.

And if the service layer only forwards method calls one by one, it is not really a business layer either.

That does not mean repositories and services are bad.

It means they become useful when they have a clear responsibility.

That is the version I would recommend today.


When this structure is actually useful

I would not add repositories and services to every small Laravel CRUD app.

For a very simple feature, using Eloquent directly in a controller can be acceptable, especially early on.

This structure becomes much more useful when:

  • query logic is reused in several places
  • business rules start growing
  • writes involve multiple actions or side effects
  • controllers are getting too large
  • you want better testability around domain logic
  • you want to separate “how data is stored” from “what the app should do”

So this is not about turning every Laravel project into enterprise architecture.

It is about introducing structure when it starts paying for itself.


The main issue with generic BaseRepository examples

In my opinion, the biggest weakness in many examples is the generic BaseRepository.

You often see something like this:

interface BaseRepositoryInterface
{
    public function getAll();
    public function findById($id);
    public function create(array $attributes);
    public function update($id, array $attributes);
    public function delete($id);
}
Enter fullscreen mode Exit fullscreen mode

And then a base implementation shared by every model.

The problem is that this often ends up duplicating what Eloquent already does very well.

Even worse, the abstraction starts leaking quickly:

  • sooner or later you need custom queries
  • then you add model-specific methods
  • then the “generic” abstraction stops being generic anyway

So instead of starting from a generic repository, I now prefer starting from a feature-oriented repository.

That means the interface should reflect real application needs, not just CRUD verbs.


A more practical Laravel structure

For a medium-sized Laravel app, I like something close to this:

app/
├── Http/
│   ├── Controllers/
│   │   └── Api/
│   │       └── PostController.php
│   ├── Requests/
│   │   ├── StorePostRequest.php
│   │   └── UpdatePostRequest.php
│   └── Resources/
│       └── PostResource.php
├── Models/
│   └── Post.php
├── Repositories/
│   ├── Contracts/
│   │   └── PostRepositoryInterface.php
│   └── EloquentPostRepository.php
├── Services/
│   └── PostService.php
└── Providers/
    └── AppServiceProvider.php
Enter fullscreen mode Exit fullscreen mode

This keeps each responsibility clear without creating too many layers.


Step 1: create a repository contract around the feature

Instead of a generic base repository, let’s define a contract that reflects what the feature actually needs.

<?php

declare(strict_types=1);

namespace App\\Repositories\\Contracts;

use App\\Models\\Post;
use Illuminate\\Contracts\\Pagination\\LengthAwarePaginator;

interface PostRepositoryInterface
{
    public function paginatePublished(int $perPage = 15): LengthAwarePaginator;

    public function findById(int $id): ?Post;

    public function create(array $attributes): Post;

    public function update(Post $post, array $attributes): Post;

    public function delete(Post $post): bool;
}
Enter fullscreen mode Exit fullscreen mode

A few details are intentional here:

  • findById() is clearer than a very generic find()
  • update() receives a Post instance, not only an id
  • delete() also receives the model instance, which fits Eloquent better

This is already more expressive than a generic CRUD shell.


Step 2: implement the repository with Eloquent

<?php

declare(strict_types=1);

namespace App\\Repositories;

use App\\Models\\Post;
use App\\Repositories\\Contracts\\PostRepositoryInterface;
use Illuminate\\Contracts\\Pagination\\LengthAwarePaginator;

final class EloquentPostRepository implements PostRepositoryInterface
{
    public function paginatePublished(int $perPage = 15): LengthAwarePaginator
    {
        return Post::query()
            ->where('is_published', true)
            ->latest()
            ->paginate($perPage);
    }

    public function findById(int $id): ?Post
    {
        return Post::query()->find($id);
    }

    public function create(array $attributes): Post
    {
        return Post::query()->create($attributes);
    }

    public function update(Post $post, array $attributes): Post
    {
        $post->update($attributes);

        return $post->refresh();
    }

    public function delete(Post $post): bool
    {
        return (bool) $post->delete();
    }
}
Enter fullscreen mode Exit fullscreen mode

This repository stays simple, but it already has real value:

  • query logic is centralized
  • data access is kept out of controllers
  • the contract can be mocked in service tests
  • implementation details can evolve later without touching other layers

Step 3: put real business logic in the service layer

This is the part where I changed my mind the most over time.

I no longer think a service should exist just to forward calls to the repository.

If a service does not make decisions, orchestrate actions, or apply business rules, it may not be worth having.

Here is a more useful service:

<?php

declare(strict_types=1);

namespace App\\Services;

use App\\Models\\Post;
use App\\Repositories\\Contracts\\PostRepositoryInterface;
use Illuminate\\Contracts\\Pagination\\LengthAwarePaginator;
use Illuminate\\Support\\Facades\\DB;
use Illuminate\\Support\\Str;

final class PostService
{
    public function __construct(
        private readonly PostRepositoryInterface $posts,
    ) {
    }

    public function listPublished(int $perPage = 15): LengthAwarePaginator
    {
        return $this->posts->paginatePublished($perPage);
    }

    public function create(array $data): Post
    {
        return DB::transaction(function () use ($data): Post {
            $payload = [
                'title' => $data['title'],
                'content' => $data['content'],
                'slug' => Str::slug($data['title']),
                'is_published' => $data['is_published'] ?? false,
            ];

            return $this->posts->create($payload);
        });
    }

    public function update(Post $post, array $data): Post
    {
        return DB::transaction(function () use ($post, $data): Post {
            $payload = [
                'title' => $data['title'],
                'content' => $data['content'],
                'is_published' => $data['is_published'] ?? $post->is_published,
            ];

            if ($post->title !== $data['title']) {
                $payload['slug'] = Str::slug($data['title']);
            }

            return $this->posts->update($post, $payload);
        });
    }

    public function delete(Post $post): bool
    {
        return $this->posts->delete($post);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the service has a real purpose:

  • preparing the payload
  • generating the slug
  • centralizing write rules
  • wrapping write operations in transactions

That is the type of service layer I find useful in Laravel.


Step 4: move validation to Form Requests

If you want thinner controllers, moving validation out of them is one of the easiest wins.

<?php

declare(strict_types=1);

namespace App\\Http\\Requests;

use Illuminate\\Foundation\\Http\\FormRequest;

final class StorePostRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'content' => ['required', 'string'],
            'is_published' => ['sometimes', 'boolean'],
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

And for updates, I would usually create a dedicated UpdatePostRequest.

That keeps each action explicit and prevents “one giant request class” from growing over time.


Step 5: use API Resources for response formatting

When building JSON APIs, I also prefer keeping response transformation out of controllers.

<?php

declare(strict_types=1);

namespace App\\Http\\Resources;

use Illuminate\\Http\\Request;
use Illuminate\\Http\\Resources\\Json\\JsonResource;

final class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'slug' => $this->slug,
            'content' => $this->content,
            'is_published' => $this->is_published,
            'created_at' => $this->created_at,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

That becomes especially useful when:

  • your API grows
  • fields need conditional exposure
  • you want stable response formatting
  • you want to avoid repeating transformation logic everywhere

Step 6: keep the controller focused on HTTP

At this point, the controller becomes much easier to read.

<?php

declare(strict_types=1);

namespace App\\Http\\Controllers\\Api;

use App\\Http\\Controllers\\Controller;
use App\\Http\\Requests\\StorePostRequest;
use App\\Http\\Resources\\PostResource;
use App\\Models\\Post;
use App\\Services\\PostService;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Http\\Request;
use Illuminate\\Http\\Resources\\Json\\AnonymousResourceCollection;

final class PostController extends Controller
{
    public function __construct(
        private readonly PostService $postService,
    ) {
    }

    public function index(Request $request): AnonymousResourceCollection
    {
        $perPage = (int) $request->integer('per_page', 15);

        return PostResource::collection(
            $this->postService->listPublished($perPage)
        );
    }

    public function store(StorePostRequest $request): PostResource
    {
        $post = $this->postService->create($request->validated());

        return new PostResource($post);
    }

    public function show(Post $post): PostResource
    {
        return new PostResource($post);
    }

    public function destroy(Post $post): JsonResponse
    {
        $this->postService->delete($post);

        return response()->json([
            'message' => 'Post deleted successfully.',
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the kind of controller I like seeing in Laravel projects.

It reads almost like a checklist:

  • validate the request
  • call the service
  • return the response

No heavy query logic.
No business rules scattered in actions.
No response formatting noise.


Step 7: bind the repository in the service container

Now we bind the interface to the implementation.

<?php

declare(strict_types=1);

namespace App\\Providers;

use App\\Repositories\\Contracts\\PostRepositoryInterface;
use App\\Repositories\\EloquentPostRepository;
use Illuminate\\Support\\ServiceProvider;

final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(PostRepositoryInterface::class, EloquentPostRepository::class);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is one of the reasons the pattern becomes interesting:

you code against a contract, while Laravel resolves the concrete implementation for you.

That makes later changes easier, such as:

  • replacing the implementation
  • decorating it with caching
  • mocking it in tests

What is improved compared to the older version

Here is what I would consider a real improvement over the more classic repository/service article style.

1. The repository is not generic for the sake of being generic

The repository is tied to a feature and exposes meaningful methods.

That makes the abstraction easier to justify.

2. The service contains actual business logic

It is not just a pass-through layer.

It prepares payloads, manages transactions, and centralizes behavior.

3. The controller is smaller for the right reasons

Not because logic disappeared into random files, but because each concern moved to the right place.

4. The code becomes easier to test

You can:

  • feature test the controller
  • unit test the service with a mocked repository
  • integration test the repository against the database

That testing split feels much more natural.


When I would not use repositories and services

I think this part is just as important.

I would probably avoid this structure when:

  • the feature is tiny
  • it is a quick prototype
  • Eloquent already expresses the logic clearly enough
  • there is no meaningful business logic yet
  • abstraction would only create extra files and extra indirection

Laravel is already expressive.

Sometimes the cleanest code is simply a small controller using Eloquent responsibly.

That is why I now see repositories and services as tools, not defaults.


Final thoughts

I still think repositories and services can improve a Laravel codebase a lot.

But today, I would frame it differently:

the goal is not to “add architecture”.

The goal is to make responsibilities obvious.

A good balance often looks like this:

  • controllers handle HTTP
  • Form Requests handle validation
  • services handle business rules and orchestration
  • repositories handle data access when it is worth isolating
  • Resources shape API responses

That is usually enough structure to keep a Laravel application clean without making it unnecessarily complex.

And honestly, that is the sweet spot I try to aim for now.

Top comments (0)