Back to blog
Laravel

Building a Todo App with the Service-Repository Pattern in Laravel

Why This Pattern?

In a typical Laravel app, controllers often talk directly to Eloquent models. That works fine for small apps, but as things grow you run into problems:

  • Business logic gets duplicated across controllers
  • Swapping your data source (e.g., moving from MySQL to an external API) means rewriting controllers
  • Testing is harder because your controller is tightly coupled to Eloquent

The Service-Repository pattern solves this by introducing two layers:

The Controller and Service only ever talk to the boxes on the left. The three implementations on the right are interchangeable — swap one for another and nothing upstream needs to change.

  • Controller: Handles HTTP concerns only (request/response). Talks to a Service.
  • Service: Contains business logic. Talks to a Repository interface, not a concrete class.
  • Repository: An interface that defines what data operations are possible (e.g., find, create, update, delete). The concrete implementation defines how — database, file, or REST API.

Because the Service only knows about the Repository interface, you can swap the underlying data source without touching your Service or Controller code at all. That’s the “loosely coupled” part you mentioned.

We’ll build a very basic Todo app to demonstrate this: list todos, create a todo, mark it complete, delete it.


Prerequisites

  • PHP 8.1+
  • Composer
  • A fresh Laravel installation (Laravel 10 or 11 works fine)

Set up your .env database connection and confirm php artisan migrate works before continuing.


Step 1: Create the Migration and Model

Edit the generated migration in database/migrations/xxxx_xx_xx_create_todos_table.php:

Run the migration:

Update app/Models/Todo.php:


Step 2: Define the Repository Interface

The interface is the contract. It says “any Todo repository must support these operations” — it doesn’t say how.

Create app/Repositories/TodoRepositoryInterface.php:

Notice this interface doesn’t mention Eloquent, MySQL, or anything database-specific. That’s intentional — it’s what makes it swappable later.


Step 3: Create the Eloquent Repository Implementation

This is one implementation of the interface, backed by the database via Eloquent.

Create app/Repositories/EloquentTodoRepository.php:


Step 4: Bind the Interface to the Implementation

Laravel’s service container needs to know that when something asks for TodoRepositoryInterface, it should get an EloquentTodoRepository.

Create a service provider:

In app/Providers/RepositoryServiceProvider.php:

Register the provider in bootstrap/providers.php (Laravel 11) or config/app.php (Laravel 10):

This single line — $this->app->bind(...) — is the “switchboard.” Later, if you want to swap to a file-based or API-based repository, you change this one line and nothing else in your app breaks.


Step 5: Create the Service Class

The Service depends on the interface, not the concrete class. This is what keeps it decoupled.

Create app/Services/TodoService.php:

Laravel will automatically inject EloquentTodoRepository here because of the binding we set up in Step 4 — this is called dependency injection, and it’s what makes the whole pattern work without you manually wiring things together.


Step 6: Create the Controller

The controller stays thin. It only handles HTTP input/output — no business logic, no data-access logic.

app/Http/Controllers/TodoController.php:


Step 7: Define Routes

In routes/api.php:

Run the app:

Test it with curl or Postman:

At this point you have a fully working Todo API built with the Service-Repository pattern.


Step 8: Proving the “Loosely Coupled” Part — Swap the Data Source

This is the payoff. Let’s say you want todos stored in a JSON file instead of the database — maybe for a quick prototype, or offline mode. You don’t touch the Service or Controller at all.

Create app/Repositories/FileTodoRepository.php:

Now switch the binding in RepositoryServiceProvider:

That’s it. TodoService and TodoController didn’t change one line — they don’t know or care whether data comes from MySQL or a flat file. This is the whole point of the pattern.


Recap: What Each Layer Is Responsible For

LayerResponsibilityDepends on
ControllerHTTP request/response, validation input shapeService
ServiceBusiness logic, orchestrationRepository interface
Repository interfaceContract for data operationsNothing (pure abstraction)
Repository implementationActual data access (DB, file, API)External data source