Repository, Service, DTO di Laravel API
TutorialLaravelPHP#laravel#php#api#backend

Repository, Service, DTO di Laravel API

A
Abd. Asis
Bagikan:

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:

LapisanTanggung JawabContoh
RepositorySemua interaksi dengan databaseQuery, filter, paginasi
ServiceBusiness logic dan orkestrasiValidasi kuota, kirim notifikasi
DTOStruktur data yang berpindah antar lapisanData task dari request ke service

Struktur Direktori

Sebelum menulis kode, tentukan dulu di mana setiap komponen tinggal:

CodeCODE
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:

PHPPHP
<?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:

PHPPHP
<?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:

PHPPHP
<?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:

PHPPHP
<?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:

PHPPHP
// 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:

CodeCODE
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.

Referensi

  1. 1 Laravel Documentation — Cache
  2. 2 Spatie Laravel Data — Dokumentasi Resmi
  3. 3 Laravel Documentation — Eloquent API Resources
Abd. Asis
Ditulis oleh
Abd. Asis

Software Developer dari Madura. Menulis tentang PHP, Laravel, dan pengembangan web modern dalam Bahasa Indonesia.