API Resources & Versioning di Laravel — Best Practice Desain API

Kalau kamu pernah bikin API Laravel dan langsung return User::find(1) dari controller, kamu tidak sendirian. Sekilas memang simpel dan kerjaannya beres. Tapi ada satu masalah yang tidak langsung kelihatan — kamu baru sadar pas kena sendiri.

Artikel ini bahas dua hal yang saling berkaitan: bagaimana kamu membentuk response API, dan bagaimana kamu mengelola perubahan API tanpa merusak client yang sudah ada.


🤔 Masalah yang Muncul Kalau Dibiarkan

Bayangin kamu punya endpoint /api/users/{id} yang langsung return model User.

Awalnya enak. Tidak perlu class tambahan, data langsung keluar. Tapi seiring waktu:

  • Password hash ikut ter-return karena lupa sembunyikan
  • Frontend tiba-tiba error karena kamu rename kolom name jadi full_name di database — dan response otomatis ikut berubah
  • Mobile app di Play Store yang belum di-update mendadak crash
  • Tim frontend beda kota mengeluh karena struktur response tiba-tiba tidak konsisten

Semua masalah itu bukan soal kode yang salah — tapi soal tidak ada lapisan antara internal model dan response publik.

API Resource hadir untuk jadi lapisan itu. Versioning hadir untuk memastikan perubahan tidak langsung membunuh client lama.


🎭 API Resource — “Juru Bicara Model”

Analoginya

Bayangin sebuah perusahaan besar yang punya juru bicara resmi.

Jurnalis tidak bisa langsung masuk ke kantor dan nanya ke sembarang karyawan. Semua informasi keluar lewat satu orang — juru bicara. Dia yang menentukan apa yang boleh disampaikan ke publik, dalam format apa, dan dengan kata-kata seperti apa.

Kalau ada informasi internal yang sensitif? Juru bicara tidak mengucapkannya. Kalau ada perubahan kebijakan? Juru bicara yang memformulasikannya ulang sebelum keluar.

API Resource di Laravel persis seperti itu. Dia duduk di antara model dan response — memutuskan apa yang boleh keluar dan bagaimana bentuknya.

Tanpa API Resource — Apa Yang Terjadi?

// Controller langsung return model — ini yang jangan dilakukan
public function show(User $user)
{
    return User::find($user->id);
}

Output yang keluar:

{
    "id": 1,
    "name": "Budi",
    "email": "budi@mail.com",
    "password": "$2y$10$abc...",
    "remember_token": "xyz...",
    "email_verified_at": "2024-01-01T00:00:00.000000Z",
    "created_at": "2024-01-01T00:00:00.000000Z",
    "updated_at": "2024-01-05T00:00:00.000000Z"
}

password dan remember_token ikut keluar. Kalau besok kolom name di database diganti jadi full_name, response langsung berubah — dan client yang bergantung pada field name langsung error.

Membuat API Resource

php artisan make:resource UserResource

📁 app/Http/Resources/UserResource.php

Disimpan di folder Resources. Satu Resource = satu “versi presentasi” dari model.

<?php

namespace App\Http\Resources;

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

class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            // Hanya field yang memang boleh keluar ke publik
            'id'    => $this->id,
            'name'  => $this->name,
            'email' => $this->email,

            // Boleh rename key — kalau DB berubah, cukup ubah di sini
            'joined_at' => $this->created_at->toDateString(),
        ];
    }
}

📌 Perhatikan — password, remember_token, semua field internal tidak ada di sini. Itu bukan kebetulan. Itu keputusan sadar. Dan kalau besok kolom name di DB diganti jadi full_name, kamu cukup ubah satu baris: 'name' => $this->full_name. Client tidak perlu tahu apa-apa.

Cara Memakainya di Controller

// Satu objek
public function show(User $user)
{
    return new UserResource($user);
}

// Koleksi
public function index()
{
    return UserResource::collection(User::all());
}

// Dengan pagination — links dan meta otomatis ikut
public function index()
{
    return UserResource::collection(User::paginate(15));
}

⚡ Fitur-Fitur Resource yang Wajib Tahu

Conditional Attribute — Output Berbeda per Role

Kadang ada field yang boleh dilihat admin tapi tidak boleh dilihat user biasa. Ini bisa langsung ditangani di Resource.

public function toArray(Request $request): array
{
    return [
        'id'    => $this->id,
        'name'  => $this->name,
        'email' => $this->email,

        // Hanya keluar kalau yang request adalah admin
        'phone'      => $this->when($request->user()?->isAdmin(), $this->phone),
        'ip_address' => $this->when($request->user()?->isAdmin(), $this->last_ip),
    ];
}

📌 Kalau kondisinya tidak terpenuhi, key-nya tidak ikut masuk ke response sama sekali — bukan null, tapi benar-benar tidak ada.

Relationship Loading — Hindari N+1 dengan Elegan

public function toArray(Request $request): array
{
    return [
        'id'    => $this->id,
        'name'  => $this->name,

        // whenLoaded memastikan posts hanya ikut jika sudah di-load
        // tidak akan trigger query tambahan kalau belum di-eager load
        'posts' => PostResource::collection($this->whenLoaded('posts')),
    ];
}

Di controller, kamu yang menentukan kapan relasi ikut di-load:

// Tanpa posts
return new UserResource(User::find($id));

// Dengan posts — di-eager load dulu
return new UserResource(User::with('posts')->find($id));

🗂️ Gambaran Besar — Posisi Resource di Arsitektur

Request
    ↓
Controller
    ↓
Service / Model (logika & data)
    ↓
API Resource  ← "Juru bicara" — di sinilah transformasi terjadi
    ↓
Response (JSON)

Controller boleh berubah. Service boleh refactor. Model boleh rename kolom. Selama Resource kamu jaga konsisten, client di luar tidak merasakannya.

💡 Prinsipnya: Controller boleh berubah, Service boleh berubah, Model boleh berubah — Resource tidak boleh sembarang berubah. Karena Resource adalah kontrak yang sudah kamu pegang ke client.


📡 API Versioning — “Kontrak yang Berevolusi”

Analoginya

Bayangin kamu punya franchise restoran yang sudah tersebar di 50 kota. Suatu hari kamu memutuskan untuk mengganti resep andalan — rasa dan nama menunya berubah.

Kalau kamu langsung ganti semua resto serentak, pelanggan setia yang sudah hafal menu lama akan kaget. Mungkin ada yang batal datang karena menu favorit mereka “hilang”.

Cara yang benar: buka menu edisi baru (V2) di sebelah menu lama (V1). Kasih waktu ke pelanggan untuk beralih. Baru setelah mereka sudah terbiasa, menu lama ditutup resmi.

API Versioning persis seperti itu. Kamu tidak paksa semua client upgrade serentak — kamu beri mereka jalan keluar yang terencana.

Kenapa Versioning Itu Wajib

Lihat skenario kegagalan ini:

// Response v1 yang sudah dipakai client
{ "name": "Rico Ardiansyah" }

// Setelah "update" tanpa versioning
{ "full_name": "Rico Ardiansyah" }

Satu rename field. Mobile app di Play Store yang belum update langsung crash karena name tidak ditemukan. Ratusan user terdampak. Dan kamu tidak bisa rollback begitu saja karena sisi server sudah terlanjur berubah.

Versioning melindungi client, bukan server.

Struktur Folder yang Disarankan

app/
├── Http/
│   ├── Controllers/
│   │   └── Api/
│   │       ├── V1/
│   │       │   └── UserController.php
│   │       └── V2/
│   │           └── UserController.php
│   └── Resources/
│       └── Api/
│           ├── V1/
│           │   └── UserResource.php
│           └── V2/
│               └── UserResource.php
routes/
└── api.php

📌 Yang perlu di-versioning hanya Controller dan Resource — karena keduanya bersentuhan langsung dengan Request dan Response. Model, Service, Repository? Tidak perlu ikut di-versioning. Mereka adalah logika internal yang tidak perlu klien tahu.

Routing dengan Versioning

📁 routes/api.php

use Illuminate\Support\Facades\Route;

// V1 — masih aktif, tapi akan segera deprecated
Route::prefix('v1')->group(function () {
    Route::get('/users/{user}', [\App\Http\Controllers\Api\V1\UserController::class, 'show']);
    Route::apiResource('posts', \App\Http\Controllers\Api\V1\PostController::class);
});

// V2 — versi baru dengan struktur response yang berbeda
Route::prefix('v2')->group(function () {
    Route::get('/users/{user}', [\App\Http\Controllers\Api\V2\UserController::class, 'show']);
    Route::apiResource('posts', \App\Http\Controllers\Api\V2\PostController::class);
});

Dengan begini, client lama yang masih pakai /api/v1/users/1 tetap berjalan. Client baru bisa langsung pakai /api/v2/users/1. Keduanya hidup berdampingan.


🚨 Memberi Sinyal Deprecation ke Client

Setelah V2 siap, V1 tidak langsung dimatikan. Ada proses komunikasi dulu. Standar industrinya pakai HTTP response header — ini bukan aturan Laravel, tapi standar RFC yang dipakai oleh API-API besar.

Deprecation: true
Sunset: 2026-06-01

Artinya: “Endpoint ini masih berjalan, tapi resmi akan dimatikan tanggal 1 Juni 2026. Segera migrasi.”

Client yang baik akan membaca header ini, log peringatannya, dan jadwalkan migrasi sebelum tanggal Sunset tiba.

Cara Paling Elegan — Middleware Khusus V1

php artisan make:middleware ApiV1Deprecation

📁 app/Http/Middleware/ApiV1Deprecation.php

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class ApiV1Deprecation
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        // Tambahkan header deprecation ke semua response V1
        // tanpa mengubah body response sama sekali
        return $response
            ->header('Deprecation', 'true')
            ->header('Sunset', '2026-06-01');
    }
}

Daftarkan alias di bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'api.v1.deprecated' => \App\Http\Middleware\ApiV1Deprecation::class,
    ]);
})

Pasang ke semua route V1:

Route::prefix('v1')
    ->middleware(['api', 'api.v1.deprecated'])
    ->group(function () {
        Route::get('/users/{user}', [\App\Http\Controllers\Api\V1\UserController::class, 'show']);
    });

Sekarang setiap response dari endpoint V1 otomatis membawa header ini:

HTTP/1.1 200 OK
Deprecation: true
Sunset: 2026-06-01
Content-Type: application/json

Kalau tanggal Sunset sudah lewat, shutdown V1 dengan bersih:

Route::prefix('v1')->group(function () {
    abort(410, 'API version v1 has been sunset. Please migrate to v2.');
});

📌 410 Gone — bukan 404 Not Found. 410 artinya resource ini memang sengaja dihapus secara permanen, bukan tidak ditemukan. Ini sinyal yang jelas ke client bahwa ini bukan bug, tapi keputusan.


🗺️ Kapan Harus Bikin Versi Baru?

Tidak semua perubahan perlu versi baru. Yang perlu:

  • Rename fieldnamefull_name
  • Hapus field dari response
  • Ubah tipe data — string jadi object, integer jadi string
  • Ubah behavior endpoint — yang dulu return semua, sekarang di-filter
  • Ubah struktur request — parameter yang dulu opsional jadi wajib
  • Ubah format response — yang dulu flat jadi nested

Yang tidak perlu versi baru:

  • Tambah field baru ke response (client lama akan ignore field yang tidak mereka kenal)
  • Fix bug yang memang responsenya salah
  • Perubahan internal yang tidak kelihatan dari luar

📊 Perbandingan Singkat

Tanpa ResourceDengan Resource
Field sensitif bocor✅ Bisa terjadi❌ Terkontrol
Perubahan DB langsung mengubah response✅ Otomatis❌ Harus ubah manual
Conditional output per role❌ Ribet di controller✅ Built-in when()
Relationship loading aman❌ Rawan N+1whenLoaded()
Konsistensi response❌ Tergantung controller✅ Terpusat

💡 Ingat!

Dua hal ini — Resource dan Versioning — sering dianggap optional dan baru dikerjakan kalau sudah kepepet. Padahal itu cara paling cepat untuk membuat API yang susah dirawat.

Resource itu bukan boilerplate yang memperumit. Dia adalah kontrak tertulis antara server dan client. Versioning itu bukan birokrasi yang memperlambat. Dia adalah rasa hormat ke semua orang yang sudah bergantung pada API-mu.

Kalau API-mu hanya dipakai internal dan satu tim, mungkin tidak terasa urgent. Tapi begitu ada mobile app, frontend beda tim, atau client pihak ketiga — kamu akan sangat bersyukur fondasi ini sudah ada dari hari pertama.