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)
$ composer create-project laravel/laravel todo-app
$ cd todo-app
Set up your .env database connection and confirm php artisan migrate works before continuing.
Step 1: Create the Migration and Model
$ php artisan make:model Todo -m
Edit the generated migration in database/migrations/xxxx_xx_xx_create_todos_table.php:
public function up(): void
{
Schema::create('todos', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->boolean('is_completed')->default(false);
$table->timestamps();
});
}
Run the migration:
$ php artisan migrate
Update app/Models/Todo.php:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Todo extends Model
{
protected $fillable = ['title', 'is_completed'];
}
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:
namespace App\Repositories;
interface TodoRepositoryInterface
{
public function all(): iterable;
public function find(int $id): ?array;
public function create(array $data): array;
public function update(int $id, array $data): ?array;
public function delete(int $id): bool;
}
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:
namespace App\Repositories;
use App\Models\Todo;
class EloquentTodoRepository implements TodoRepositoryInterface
{
public function all(): iterable
{
return Todo::all()->toArray();
}
public function find(int $id): ?array
{
$todo = Todo::find($id);
return $todo ? $todo->toArray() : null;
}
public function create(array $data): array
{
return Todo::create($data)->toArray();
}
public function update(int $id, array $data): ?array
{
$todo = Todo::find($id);
if (! $todo) {
return null;
}
$todo->update($data);
return $todo->toArray();
}
public function delete(int $id): bool
{
$todo = Todo::find($id);
if (! $todo) {
return false;
}
return (bool) $todo->delete();
}
}
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:
php artisan make:provider RepositoryServiceProvider
In app/Providers/RepositoryServiceProvider.php:
namespace App\Providers;
use App\Repositories\EloquentTodoRepository;
use App\Repositories\TodoRepositoryInterface;
use Illuminate\Support\ServiceProvider;
class RepositoryServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(
TodoRepositoryInterface::class,
EloquentTodoRepository::class
);
}
}
Register the provider in bootstrap/providers.php (Laravel 11) or config/app.php (Laravel 10):
// bootstrap/providers.php
return [
App\Providers\AppServiceProvider::class,
App\Providers\RepositoryServiceProvider::class,
];
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:
namespace App\Services;
use App\Repositories\TodoRepositoryInterface;
class TodoService
{
public function __construct(
protected TodoRepositoryInterface $todoRepository
) {}
public function getAllTodos(): iterable
{
return $this->todoRepository->all();
}
public function getTodo(int $id): ?array
{
return $this->todoRepository->find($id);
}
public function createTodo(array $data): array
{
// Business logic can live here, e.g. validation, defaults, events
$data['is_completed'] = $data['is_completed'] ?? false;
return $this->todoRepository->create($data);
}
public function updateTodo(int $id, array $data): ?array
{
return $this->todoRepository->update($id, $data);
}
public function deleteTodo(int $id): bool
{
return $this->todoRepository->delete($id);
}
public function markAsComplete(int $id): ?array
{
return $this->todoRepository->update($id, ['is_completed' => true]);
}
}
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.
$ php artisan make:controller TodoController
app/Http/Controllers/TodoController.php:
namespace App\Http\Controllers;
use App\Services\TodoService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class TodoController extends Controller
{
public function __construct(
protected TodoService $todoService
) {}
public function index(): JsonResponse
{
return response()->json($this->todoService->getAllTodos());
}
public function show(int $id): JsonResponse
{
$todo = $this->todoService->getTodo($id);
if (! $todo) {
return response()->json(['message' => 'Todo not found'], 404);
}
return response()->json($todo);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
]);
$todo = $this->todoService->createTodo($validated);
return response()->json($todo, 201);
}
public function update(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
'is_completed' => 'sometimes|boolean',
]);
$todo = $this->todoService->updateTodo($id, $validated);
if (! $todo) {
return response()->json(['message' => 'Todo not found'], 404);
}
return response()->json($todo);
}
public function complete(int $id): JsonResponse
{
$todo = $this->todoService->markAsComplete($id);
if (! $todo) {
return response()->json(['message' => 'Todo not found'], 404);
}
return response()->json($todo);
}
public function destroy(int $id): JsonResponse
{
$deleted = $this->todoService->deleteTodo($id);
if (! $deleted) {
return response()->json(['message' => 'Todo not found'], 404);
}
return response()->json(['message' => 'Todo deleted']);
}
}
Step 7: Define Routes
In routes/api.php:
use App\Http\Controllers\TodoController;
Route::get('/todos', [TodoController::class, 'index']);
Route::get('/todos/{id}', [TodoController::class, 'show']);
Route::post('/todos', [TodoController::class, 'store']);
Route::put('/todos/{id}', [TodoController::class, 'update']);
Route::patch('/todos/{id}/complete', [TodoController::class, 'complete']);
Route::delete('/todos/{id}', [TodoController::class, 'destroy']);
Run the app:
$ php artisan serve
Test it with curl or Postman:
$ curl -X POST http://localhost:8000/api/todos -d "title=Buy milk"
$ curl http://localhost:8000/api/todos
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:
namespace App\Repositories;
class FileTodoRepository implements TodoRepositoryInterface
{
protected string $path;
public function __construct()
{
$this->path = storage_path('app/todos.json');
if (! file_exists($this->path)) {
file_put_contents($this->path, json_encode([]));
}
}
protected function read(): array
{
return json_decode(file_get_contents($this->path), true) ?? [];
}
protected function write(array $todos): void
{
file_put_contents($this->path, json_encode($todos));
}
public function all(): iterable
{
return $this->read();
}
public function find(int $id): ?array
{
$todos = $this->read();
foreach ($todos as $todo) {
if ($todo['id'] === $id) {
return $todo;
}
}
return null;
}
public function create(array $data): array
{
$todos = $this->read();
$data['id'] = count($todos) ? max(array_column($todos, 'id')) + 1 : 1;
$todos[] = $data;
$this->write($todos);
return $data;
}
public function update(int $id, array $data): ?array
{
$todos = $this->read();
foreach ($todos as &$todo) {
if ($todo['id'] === $id) {
$todo = array_merge($todo, $data);
$this->write($todos);
return $todo;
}
}
return null;
}
public function delete(int $id): bool
{
$todos = $this->read();
$filtered = array_filter($todos, fn ($todo) => $todo['id'] !== $id);
if (count($filtered) === count($todos)) {
return false;
}
$this->write(array_values($filtered));
return true;
}
}
Now switch the binding in RepositoryServiceProvider:
//PHP
$this->app->bind(
TodoRepositoryInterface::class,
FileTodoRepository::class // was EloquentTodoRepository::class
);
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
| Layer | Responsibility | Depends on |
|---|---|---|
| Controller | HTTP request/response, validation input shape | Service |
| Service | Business logic, orchestration | Repository interface |
| Repository interface | Contract for data operations | Nothing (pure abstraction) |
| Repository implementation | Actual data access (DB, file, API) | External data source |