Controller yang gemuk adalah masalah klasik di Laravel. Semuanya tumpah di sana: query Eloquent, validasi manual, kalkulasi bisnis, hingga cache invalidation. Kode yang terlihat wajar di sprint pertama bisa berubah menjadi labirin di sprint ke-sepuluh.
Repository Pattern, Service Layer, dan DTO hadir sebagai solusi arsitektur yang memisahkan tanggung jawab masing-masing komponen. Bukan sekadar teori akademis — kombinasi ini terbukti membuat codebase Laravel tetap sehat saat tim dan fitur bertumbuh.
Artikel ini membahas implementasi ketiga pola tersebut secara end-to-end, mulai dari struktur direktori hingga strategi caching, dengan skenario aplikasi manajemen proyek sebagai konteks.
Mengapa Arsitektur Ini Penting
Bayangkan kamu punya endpoint API untuk membuat task baru. Di controller biasa, satu method store() bisa berisi: validasi request, pembuatan slug, pengecekan kuota proyek, penyimpanan ke database, trigger notifikasi, dan invalidasi cache. Semua dalam satu tempat.
Masalahnya bukan cuma soal panjang kode — tapi soal ketergantungan. Logic kuota proyek tidak bisa dipakai ulang di command Artisan. Perubahan cara caching memaksa kamu membuka controller. Testing satu bagian kecil berarti memuat semua dependency yang lain.
Arsitektur Repository-Service-DTO memecah masalah ini menjadi tiga lapisan yang masing-masing punya satu tanggung jawab:
| Lapisan | Tanggung Jawab | Contoh |
|---|---|---|
| Repository | Semua interaksi dengan database | Query, filter, paginasi |
| Service | Business logic dan orkestrasi | Validasi kuota, kirim notifikasi |
| DTO | Struktur data yang berpindah antar lapisan | Data task dari request ke service |
Struktur Direktori
Sebelum menulis kode, tentukan dulu di mana setiap komponen tinggal:
app/
├── Http/
│ ├── Controllers/Api/
│ ├── Requests/
│ └── Resources/
├── Models/
├── Repositories/
├── Services/
└── DTOs/
Tidak perlu membuat folder baru di luar app/ — semua komponen ini masuk dalam struktur Laravel yang sudah ada.
Repository: Satu Pintu ke Database
Repository adalah satu-satunya tempat di mana query Eloquent boleh hidup. Controller dan Service tidak boleh memanggil model secara langsung.
Buat file app/Repositories/TaskRepository.php:
<?php
namespace App\Repositories;
use App\Models\Task;
use Illuminate\Pagination\LengthAwarePaginator;
class TaskRepository
{
public function paginateByProject(int $projectId, int $perPage = 15): LengthAwarePaginator
{
return Task::query()
->where('project_id', $projectId)
->with(['assignee', 'labels'])
->latest()
->paginate($perPage);
}
public function findOrFail(int $id): Task
{
return Task::with(['assignee', 'labels'])->findOrFail($id);
}
public function create(array $attributes): Task
{
return Task::create($attributes);
}
public function update(Task $task, array $attributes): Task
{
$task->update($attributes);
return $task->fresh();
}
public function delete(Task $task): void
{
$task->delete();
}
public function countByProject(int $projectId): int
{
return Task::where('project_id', $projectId)->count();
}
}
Perhatikan bahwa setiap method punya return type yang eksplisit. Ini bukan sekadar gaya — ini dokumentasi yang langsung dimengerti IDE dan static analyzer.
DTO: Struktur Data yang Bisa Dipercaya
DTO menggantikan array asosiatif yang tidak punya jaminan struktur. Alih-alih $data['title'] yang bisa undefined kapan saja, kita pakai objek dengan properti yang jelas dan bertipe.
Buat app/DTOs/TaskData.php:
<?php
namespace App\DTOs;
use Illuminate\Foundation\Http\FormRequest;
class TaskData
{
public function __construct(
public readonly string $title,
public readonly int $projectId,
public readonly string $priority,
public readonly ?string $description = null,
public readonly ?int $assigneeId = null,
public readonly ?string $dueDate = null,
) {}
public static function fromRequest(FormRequest $request): self
{
return new self(
title: $request->string('title')->toString(),
projectId: $request->integer('project_id'),
priority: $request->string('priority', 'medium')->toString(),
description: $request->string('description')->toString() ?: null,
assigneeId: $request->integer('assignee_id') ?: null,
dueDate: $request->input('due_date'),
);
}
public function toArray(): array
{
return array_filter([
'title' => $this->title,
'project_id' => $this->projectId,
'priority' => $this->priority,
'description' => $this->description,
'assignee_id' => $this->assigneeId,
'due_date' => $this->dueDate,
], fn ($value) => $value !== null);
}
}
Penggunaan readonly di PHP 8.1+ memastikan DTO tidak bisa dimodifikasi setelah dibuat — sesuai dengan sifatnya sebagai objek transfer data yang immutable.
Jika project sudah menggunakan package seperti spatie/laravel-data, pertimbangkan untuk menggunakannya sebagai pengganti DTO manual. Package ini menyediakan casting, validation, dan transformasi otomatis yang lebih kaya.
Service Layer: Tempat Logic Bisnis Tinggal
Service mengorkestrasikan alur bisnis menggunakan Repository dan DTO. Inilah tempat aturan-aturan seperti "project tidak boleh punya lebih dari 100 task aktif" hidup — bukan di controller, bukan di model.
Buat app/Services/TaskService.php:
<?php
namespace App\Services;
use App\DTOs\TaskData;
use App\Models\Task;
use App\Repositories\TaskRepository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\ValidationException;
class TaskService
{
private const MAX_TASKS_PER_PROJECT = 100;
private const CACHE_TTL = 300;
public function __construct(
protected TaskRepository $repository,
) {}
public function createTask(TaskData $data): Task
{
$this->enforceProjectTaskLimit($data->projectId);
$task = $this->repository->create($data->toArray());
$this->invalidateProjectCache($data->projectId);
return $task;
}
public function updateTask(Task $task, TaskData $data): Task
{
$updated = $this->repository->update($task, $data->toArray());
$this->invalidateProjectCache($data->projectId);
return $updated;
}
public function deleteTask(Task $task): void
{
$projectId = $task->project_id;
$this->repository->delete($task);
$this->invalidateProjectCache($projectId);
}
public function getProjectTasks(int $projectId, int $page = 1): mixed
{
return Cache::remember(
"project.{$projectId}.tasks.page.{$page}",
self::CACHE_TTL,
fn () => $this->repository->paginateByProject($projectId)
);
}
private function enforceProjectTaskLimit(int $projectId): void
{
$count = $this->repository->countByProject($projectId);
if ($count >= self::MAX_TASKS_PER_PROJECT) {
throw ValidationException::withMessages([
'project_id' => "Proyek ini sudah mencapai batas maksimal " . self::MAX_TASKS_PER_PROJECT . " task.",
]);
}
}
private function invalidateProjectCache(int $projectId): void
{
Cache::forget("project.{$projectId}.tasks.page.1");
}
}
Logic pengecekan limit dikemas dalam method private enforceProjectTaskLimit(). Ini bisa dipanggil dari method manapun di service ini, dan bisa dipindahkan ke service lain jika suatu saat dibutuhkan.
Controller: Bersih dan Fokus ke HTTP
Controller yang menggunakan service menjadi sangat tipis. Tugasnya hanya tiga: terima request, delegasikan ke service, kembalikan response.
Buat app/Http/Controllers/Api/TaskController.php:
<?php
namespace App\Http\Controllers\Api;
use App\DTOs\TaskData;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreTaskRequest;
use App\Http\Resources\TaskResource;
use App\Models\Task;
use App\Services\TaskService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class TaskController extends Controller
{
public function __construct(
protected TaskService $service,
) {}
public function index(int $projectId): AnonymousResourceCollection
{
$page = request()->integer('page', 1);
$tasks = $this->service->getProjectTasks($projectId, $page);
return TaskResource::collection($tasks);
}
public function store(StoreTaskRequest $request): JsonResponse
{
$data = TaskData::fromRequest($request);
$task = $this->service->createTask($data);
return response()->json([
'message' => 'Task berhasil dibuat.',
'data' => new TaskResource($task),
], 201);
}
public function update(StoreTaskRequest $request, Task $task): JsonResponse
{
$data = TaskData::fromRequest($request);
$task = $this->service->updateTask($task, $data);
return response()->json([
'message' => 'Task berhasil diperbarui.',
'data' => new TaskResource($task),
]);
}
public function destroy(Task $task): JsonResponse
{
$this->service->deleteTask($task);
return response()->json(['message' => 'Task berhasil dihapus.']);
}
}
Controller tidak tahu bagaimana task dibuat, tidak tahu ada limit proyek, dan tidak tahu ada cache. Semua itu urusan service.
Caching di Service Layer
Strategi caching yang tepat bisa membuat API terasa jauh lebih cepat tanpa mengubah database schema. Letakkan caching di service, bukan di repository, agar logic cache tetap dekat dengan business rules yang menentukan kapan cache harus diinvalidasi.
Untuk kasus yang lebih kompleks dengan banyak halaman, gunakan cache tags:
// Dengan Redis yang mendukung cache tags
public function getProjectTasks(int $projectId, int $page = 1): mixed
{
return Cache::tags(["project:{$projectId}"])->remember(
"tasks.page.{$page}",
self::CACHE_TTL,
fn () => $this->repository->paginateByProject($projectId)
);
}
private function invalidateProjectCache(int $projectId): void
{
Cache::tags(["project:{$projectId}"])->flush();
}
Cache tags memungkinkan kita membuang semua cache yang terkait satu proyek sekaligus, tanpa perlu melacak satu per satu key-nya.
Cache tags tidak didukung oleh driver file dan database. Pastikan menggunakan Redis atau Memcached di production sebelum mengaktifkan cache tags.
Alur Data End-to-End
Untuk membantu membangun mental model, berikut perjalanan data dari HTTP request hingga response:
HTTP Request
↓
FormRequest (validasi & sanitasi)
↓
Controller (delegasi ke service)
↓
DTO (struktur data dari request)
↓
Service (business logic, cache, orchestration)
↓
Repository (query database)
↓
Model → Database
↓
API Resource (transformasi response)
↓
JSON Response
Setiap panah mewakili batas tanggung jawab yang jelas. Kalau kamu perlu mengubah cara paginasi bekerja, cukup buka repository. Kalau ada perubahan aturan bisnis, cukup buka service.
Kapan Arsitektur Ini Masuk Akal
Arsitektur ini membawa nilai nyata dalam konteks tertentu, tapi juga ada biaya setup yang perlu diperhitungkan.
Gunakan kombinasi Repository-Service-DTO jika:
- API kamu dikonsumsi oleh lebih dari satu client (mobile, web, third-party)
- Ada business logic yang kompleks atau terus berkembang
- Tim lebih dari satu orang dan perlu konvensi yang jelas
- Kamu berencana menulis automated tests secara serius
Untuk aplikasi internal yang sederhana atau prototype, controller biasa dengan Eloquent langsung sudah cukup. Jangan over-engineer sesuatu yang belum membutuhkannya.
Kesimpulan
Repository Pattern, Service Layer, dan DTO bukan tentang menambah file — mereka tentang memberi setiap bagian kode tempat yang tepat untuk tinggal. Hasilnya adalah controller yang bisa dibaca dalam 30 detik, service yang bisa ditest tanpa HTTP request, dan repository yang bisa diganti implementasinya tanpa menyentuh business logic.
Langkah selanjutnya yang natural adalah menambahkan interface pada repository untuk memudahkan dependency injection saat testing, dan mengeksplorasi Eloquent API Resources untuk kontrol penuh atas shape JSON response yang dikembalikan.







