Di bagian sebelumnya kita sudah menguasai file upload dan storage. Sekarang saatnya membahas topik yang sangat relevan di era aplikasi modern: membangun REST API. Hampir semua aplikasi mobile, SPA (Single Page Application), atau integrasi antar sistem membutuhkan API sebagai "jembatan" pertukaran data. Laravel menyediakan Sanctum sebagai solusi autentikasi API yang ringan namun powerful — cocok untuk SPA, aplikasi mobile, hingga token-based API sederhana.

💡 Ini adalah bagian kedua belas dari seri "Belajar Laravel dari Nol". Di bagian ini kita akan membangun REST API lengkap dengan Sanctum untuk autentikasi token. Materi ini sangat relevan jika kamu membangun backend untuk aplikasi Flutter, React Native, atau frontend JavaScript — termasuk untuk project seperti riwayat-hidup.id yang membutuhkan REST API untuk aplikasi mobile.


Apa Itu REST API dan Sanctum?

REST API (Representational State Transfer Application Programming Interface) adalah cara aplikasi berkomunikasi satu sama lain melalui HTTP — mengirim dan menerima data dalam format JSON. Sanctum adalah package autentikasi resmi Laravel yang menyediakan dua mekanisme: token API sederhana untuk aplikasi mobile atau pihak ketiga, dan session-based authentication untuk SPA yang domain-nya sama dengan backend.

"Laravel Sanctum provides a featherweight authentication system for SPAs, mobile applications, and simple token based APIs."

— — Dokumentasi Resmi Laravel

Instalasi dan Konfigurasi Sanctum

Laravel Sanctum sudah termasuk dalam instalasi Laravel default sejak versi 11. Kamu hanya perlu menjalankan migration untuk membuat tabel personal_access_tokens dan melakukan konfigurasi minimal sebelum bisa mulai menggunakannya.

Bash
# Sanctum sudah termasuk di Laravel 11+ secara default
# Cek apakah sudah ada di composer.json:
# "laravel/sanctum": "^4.0"

# Jika belum ada, install manual:
composer require laravel/sanctum

# Publish konfigurasi dan migration Sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

# Jalankan migration (membuat tabel personal_access_tokens)
php artisan migrate

# Verifikasi tabel sudah ada:
php artisan db:show
# Harus ada tabel: personal_access_tokens
PHP
<?php
// config/sanctum.php — Konfigurasi penting

return [
    // Berapa lama token berlaku (null = tidak kedaluwarsa)
    // Dalam menit — 525600 = 1 tahun
    'expiration' => null,

    // Expiration untuk token yang dihasilkan via stateful SPA
    'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),

    // Domain yang diizinkan untuk SPA authentication
    // Sesuaikan dengan domain frontend kamu
    'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', implode(',', [
        'localhost',
        'localhost:3000',
        '127.0.0.1',
        '127.0.0.1:8000',
        '::1',
        // Tambahkan domain production:
        // 'app.devpath.id',
    ]))),

    'guard' => ['web'],

    'middleware' => [
        'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
        'encrypt_cookies'      => Illuminate\Cookie\Middleware\EncryptCookies::class,
        'validate_csrf_token'  => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
    ],
];
PHP
<?php
// app/Models/User.php — Tambahkan trait HasApiTokens

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Sanctum\HasApiTokens; // ← Import trait

class User extends Authenticatable
{
    use HasApiTokens; // ← Tambahkan trait ini
    // ... kode lainnya

    // HasApiTokens menambahkan method:
    // - $user->createToken('token-name') → buat token baru
    // - $user->tokens()                  → semua token milik user
    // - $user->currentAccessToken()      → token yang sedang dipakai
}

Struktur API yang Akan Kita Bangun

Sebelum menulis kode, penting untuk merancang struktur API yang akan dibangun. Kita akan membangun API blog lengkap yang bisa dikonsumsi oleh aplikasi mobile Flutter atau frontend JavaScript — mencakup autentikasi, CRUD post, dan beberapa endpoint tambahan yang umum dibutuhkan.

PHP
─── ENDPOINT API YANG AKAN DIBANGUN ─────────────────────

BASE URL: https://api.devpath.id/api/v1

AUTH ENDPOINTS (tidak butuh token):
POST   /auth/register        → Daftar akun baru
POST   /auth/login           → Login, dapatkan token
POST   /auth/forgot-password → Kirim link reset password
POST   /auth/reset-password  → Reset password dengan token

AUTH ENDPOINTS (butuh token):
POST   /auth/logout          → Logout (hapus token)
GET    /auth/me              → Data user yang sedang login
PUT    /auth/profile         → Update profil
POST   /auth/avatar          → Upload avatar

POST ENDPOINTS (publik):
GET    /posts                → Daftar post (dengan filter & pagination)
GET    /posts/{slug}         → Detail satu post
GET    /categories           → Daftar kategori

POST ENDPOINTS (butuh token):
POST   /posts                → Buat post baru
PUT    /posts/{slug}         → Update post
DELETE /posts/{slug}         → Hapus post
POST   /posts/{slug}/like    → Like/unlike post

COMMENT ENDPOINTS (butuh token):
GET    /posts/{slug}/comments   → Daftar komentar
POST   /posts/{slug}/comments   → Tambah komentar
DELETE /comments/{id}           → Hapus komentar

FORMAT RESPONSE STANDAR:
{
  "success": true,
  "message": "Data berhasil diambil",
  "data": { ... },
  "meta": {           ← Untuk pagination
    "current_page": 1,
    "last_page": 5,
    "per_page": 15,
    "total": 73
  }
}

Konfigurasi Route API

Semua endpoint API didefinisikan di file routes/api.php. Berbeda dengan routes/web.php, route di sini menggunakan middleware api yang bersifat stateless — tidak ada session, tidak ada cookie CSRF, dan setiap request harus menyertakan token autentikasi di header.

PHP
<?php
// routes/api.php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\{
    AuthController,
    PostController,
    CategoryController,
    CommentController,
};

// Prefix versi API — sangat direkomendasikan untuk maintainability
Route::prefix('v1')->name('api.v1.')->group(function () {

    // ── AUTH ROUTES (tidak butuh token) ───────────────
    Route::prefix('auth')->name('auth.')->group(function () {
        Route::post('register',         [AuthController::class, 'register'])->name('register');
        Route::post('login',            [AuthController::class, 'login'])->name('login');
        Route::post('forgot-password',  [AuthController::class, 'forgotPassword'])->name('forgot-password');
        Route::post('reset-password',   [AuthController::class, 'resetPassword'])->name('reset-password');
    });

    // ── PUBLIC ROUTES (tidak butuh token) ─────────────
    Route::get('posts',              [PostController::class, 'index'])->name('posts.index');
    Route::get('posts/{post:slug}',  [PostController::class, 'show'])->name('posts.show');
    Route::get('categories',         [CategoryController::class, 'index'])->name('categories.index');
    Route::get('posts/{post:slug}/comments', [CommentController::class, 'index'])
        ->name('posts.comments.index');

    // ── PROTECTED ROUTES (butuh token Sanctum) ────────
    Route::middleware('auth:sanctum')->group(function () {

        // Auth user routes
        Route::prefix('auth')->name('auth.')->group(function () {
            Route::post('logout',  [AuthController::class, 'logout'])->name('logout');
            Route::get('me',       [AuthController::class, 'me'])->name('me');
            Route::put('profile',  [AuthController::class, 'updateProfile'])->name('update-profile');
            Route::post('avatar',  [AuthController::class, 'uploadAvatar'])->name('upload-avatar');
        });

        // Post management
        Route::post('posts',              [PostController::class, 'store'])->name('posts.store');
        Route::put('posts/{post:slug}',   [PostController::class, 'update'])->name('posts.update');
        Route::delete('posts/{post:slug}',[PostController::class, 'destroy'])->name('posts.destroy');
        Route::post('posts/{post:slug}/like', [PostController::class, 'toggleLike'])
            ->name('posts.like');

        // Comments
        Route::post('posts/{post:slug}/comments', [CommentController::class, 'store'])
            ->name('posts.comments.store');
        Route::delete('comments/{comment}', [CommentController::class, 'destroy'])
            ->name('comments.destroy');
    });
});

API Resource: Format Response yang Konsisten

Salah satu best practice API adalah memiliki format response yang konsisten — struktur JSON yang selalu sama di semua endpoint. Laravel menyediakan API Resource untuk mengontrol persis bagaimana data Model ditransformasi menjadi JSON response, termasuk memilih kolom mana yang ditampilkan, menambahkan data kalkulasi, dan menyertakan relasi.

#
Buat API Resource dengan Artisan

API Resource adalah kelas transformasi yang menentukan bagaimana sebuah Model ditampilkan dalam JSON. Artisan membuat file di app/Http/Resources/ yang bisa langsung dikustomisasi.

#
Definisikan Transformasi di Method toArray()

Method toArray() mengembalikan array yang akan dikonversi ke JSON. Di sini kamu menentukan kolom mana yang ditampilkan, format seperti apa, dan relasi apa yang disertakan.

#
Gunakan Resource di Controller

Kembalikan instance Resource dari Controller alih-alih Model langsung. Resource Collection digunakan untuk array atau paginated result, sementara Resource tunggal untuk satu record.

Bash
# Buat API Resource
php artisan make:resource PostResource
php artisan make:resource PostCollection  # Atau gunakan --collection
php artisan make:resource UserResource
php artisan make:resource CommentResource

# File dibuat di: app/Http/Resources/
PHP
<?php
// app/Http/Resources/PostResource.php

namespace App\Http\Resources;

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

class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'           => $this->id,
            'title'        => $this->title,
            'slug'         => $this->slug,
            'excerpt'      => $this->excerpt,
            'content'      => $this->content,

            // URL lengkap thumbnail (bukan hanya path)
            'thumbnail'    => $this->thumbnail_url,

            'status'       => $this->status,
            'is_featured'  => $this->is_featured,
            'views'        => $this->views,
            'read_time'    => $this->read_time,

            // Format tanggal yang konsisten
            'published_at' => $this->published_at?->toIso8601String(),
            'created_at'   => $this->created_at->toIso8601String(),
            'updated_at'   => $this->updated_at->toIso8601String(),

            // Relasi — hanya disertakan jika sudah di-load (whenLoaded)
            // Tidak menyebabkan N+1 jika relasi belum di-load
            'author'    => new UserResource($this->whenLoaded('user')),
            'category'  => new CategoryResource($this->whenLoaded('category')),
            'tags'      => TagResource::collection($this->whenLoaded('tags')),
            'comments'  => CommentResource::collection($this->whenLoaded('approvedComments')),

            // Data yang dihitung
            'comments_count' => $this->whenCounted('comments'),
            'likes_count'    => $this->whenCounted('likes'),

            // Data kondisional — hanya muncul jika kondisi terpenuhi
            // Hanya tampilkan ini jika user yang request adalah pemilik
            'is_mine' => $this->when(
                auth()->id() === $this->user_id,
                true
            ),

            // Meta data untuk SEO
            'meta' => $this->when($this->meta_title || $this->meta_description, [
                'title'       => $this->meta_title,
                'description' => $this->meta_description,
            ]),
        ];
    }
}
PHP
<?php
// app/Http/Resources/UserResource.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 [
            'id'         => $this->id,
            'name'       => $this->name,
            'email'      => $this->email,

            // Avatar URL
            'avatar'     => $this->avatar
                ? \Storage::url($this->avatar)
                : 'https://ui-avatars.com/api/?name=' . urlencode($this->name),

            // Profil — hanya jika sudah di-load
            'bio'        => $this->whenLoaded('profile', fn() => $this->profile->bio),
            'website'    => $this->whenLoaded('profile', fn() => $this->profile->website),

            // Data sensitif hanya muncul untuk user sendiri
            'email_verified_at' => $this->when(
                auth()->id() === $this->id,
                $this->email_verified_at?->toIso8601String()
            ),
            'is_author'  => $this->when(auth()->id() === $this->id, $this->is_author),
            'is_admin'   => $this->when(auth()->id() === $this->id, $this->is_admin),

            'joined_at'  => $this->created_at->toIso8601String(),

            // Count relasi
            'posts_count' => $this->whenCounted('posts'),
        ];
    }
}

Auth Controller untuk API

Controller API berbeda dari Controller web — alih-alih mengembalikan view atau redirect, semua response adalah JSON. Mari buat Auth Controller yang menangani register, login, logout, dan management profil dengan format response yang konsisten.

PHP
<?php
// app/Http/Controllers/Api/V1/AuthController.php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\{Auth, Hash, Password};
use Illuminate\Validation\Rules\Password as PasswordRule;
use Illuminate\Auth\Events\Registered;

class AuthController extends Controller
{
    /**
     * POST /api/v1/auth/register
     */
    public function register(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'name'     => 'required|string|max:255',
            'email'    => 'required|email|unique:users,email',
            'password' => ['required', 'confirmed', PasswordRule::min(8)],
        ]);

        $user = User::create([
            'name'     => $validated['name'],
            'email'    => $validated['email'],
            'password' => Hash::make($validated['password']),
        ]);

        event(new Registered($user));

        $token = $user->createToken('auth_token')->plainTextToken;

        return response()->json([
            'success' => true,
            'message' => 'Registrasi berhasil! Silakan verifikasi email kamu.',
            'data'    => [
                'user'  => new UserResource($user),
                'token' => $token,
                'token_type' => 'Bearer',
            ],
        ], 201);
    }

    /**
     * POST /api/v1/auth/login
     */
    public function login(Request $request): JsonResponse
    {
        $request->validate([
            'email'    => 'required|email',
            'password' => 'required|string',
        ]);

        // Cek credentials
        if (!Auth::attempt($request->only('email', 'password'))) {
            return response()->json([
                'success' => false,
                'message' => 'Email atau password tidak valid.',
                'errors'  => ['email' => ['Email atau password salah.']],
            ], 401);
        }

        $user = Auth::user();

        // Cek apakah akun aktif
        if (!$user->is_active) {
            return response()->json([
                'success' => false,
                'message' => 'Akun kamu telah dinonaktifkan.',
            ], 403);
        }

        // Hapus token lama jika tidak ingin multi-device login
        // $user->tokens()->delete();

        // Buat token baru
        // Parameter kedua adalah abilities/scopes (opsional)
        $token = $user->createToken(
            name: 'auth_token_' . $request->ip(),
            abilities: ['*'], // Semua kemampuan
            expiresAt: now()->addDays(30) // Token kedaluwarsa 30 hari
        )->plainTextToken;

        return response()->json([
            'success' => true,
            'message' => 'Login berhasil!',
            'data'    => [
                'user'       => new UserResource($user),
                'token'      => $token,
                'token_type' => 'Bearer',
                'expires_at' => now()->addDays(30)->toIso8601String(),
            ],
        ]);
    }

    /**
     * POST /api/v1/auth/logout
     * Membutuhkan token (middleware auth:sanctum)
     */
    public function logout(Request $request): JsonResponse
    {
        // Hapus token yang sedang dipakai saja
        $request->user()->currentAccessToken()->delete();

        // Atau hapus semua token user (logout dari semua device)
        // $request->user()->tokens()->delete();

        return response()->json([
            'success' => true,
            'message' => 'Berhasil logout.',
        ]);
    }

    /**
     * GET /api/v1/auth/me
     */
    public function me(Request $request): JsonResponse
    {
        return response()->json([
            'success' => true,
            'data'    => new UserResource(
                $request->user()->load('profile')
            ),
        ]);
    }

    /**
     * PUT /api/v1/auth/profile
     */
    public function updateProfile(Request $request): JsonResponse
    {
        $user = $request->user();

        $validated = $request->validate([
            'name'    => 'sometimes|string|max:255',
            'bio'     => 'sometimes|nullable|string|max:500',
            'website' => 'sometimes|nullable|url',
        ]);

        $user->update($validated);

        return response()->json([
            'success' => true,
            'message' => 'Profil berhasil diperbarui.',
            'data'    => new UserResource($user->load('profile')),
        ]);
    }

    /**
     * POST /api/v1/auth/forgot-password
     */
    public function forgotPassword(Request $request): JsonResponse
    {
        $request->validate(['email' => 'required|email']);

        $status = Password::sendResetLink($request->only('email'));

        if ($status !== Password::RESET_LINK_SENT) {
            return response()->json([
                'success' => false,
                'message' => 'Email tidak ditemukan.',
            ], 404);
        }

        return response()->json([
            'success' => true,
            'message' => 'Link reset password sudah dikirim ke email kamu.',
        ]);
    }
}

Post Controller untuk API

Mari buat Post Controller API yang menangani semua operasi CRUD dengan format response yang konsisten, filter yang fleksibel, dan pagination yang ramah untuk dikonsumsi aplikasi mobile.

PHP
<?php
// app/Http/Controllers/Api/V1/PostController.php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\{JsonResponse, Request};
use Illuminate\Support\Facades\DB;

class PostController extends Controller
{
    /**
     * GET /api/v1/posts
     * Daftar post dengan filter, search, dan pagination
     */
    public function index(Request $request): JsonResponse
    {
        $posts = Post::published()
            // Filter opsional
            ->when($request->category,
                fn($q, $cat) => $q->whereHas('category', fn($q) => $q->where('slug', $cat))
            )
            ->when($request->tag,
                fn($q, $tag) => $q->whereHas('tags', fn($q) => $q->where('slug', $tag))
            )
            ->when($request->search, fn($q, $s) => $q->where(function ($q) use ($s) {
                $q->where('title', 'like', "%{$s}%")
                  ->orWhere('excerpt', 'like', "%{$s}%");
            }))
            ->when($request->featured, fn($q) => $q->where('is_featured', true))
            ->when($request->sort === 'popular',
                fn($q) => $q->orderByDesc('views'),
                fn($q) => $q->latest('published_at')
            )
            // Eager loading
            ->with(['user:id,name,avatar', 'category:id,name,slug,color'])
            ->withCount(['comments', 'likes'])
            // Pagination (default 15, maks 50)
            ->paginate(min($request->integer('per_page', 15), 50));

        return response()->json([
            'success' => true,
            'message' => 'Data berhasil diambil.',
            'data'    => PostResource::collection($posts),
            'meta'    => [
                'current_page' => $posts->currentPage(),
                'last_page'    => $posts->lastPage(),
                'per_page'     => $posts->perPage(),
                'total'        => $posts->total(),
                'from'         => $posts->firstItem(),
                'to'           => $posts->lastItem(),
            ],
            'links'   => [
                'first' => $posts->url(1),
                'last'  => $posts->url($posts->lastPage()),
                'prev'  => $posts->previousPageUrl(),
                'next'  => $posts->nextPageUrl(),
            ],
        ]);
    }

    /**
     * GET /api/v1/posts/{slug}
     */
    public function show(Post $post): JsonResponse
    {
        // Cek authorization
        if ($post->status !== 'published' && auth('sanctum')->id() !== $post->user_id) {
            return response()->json([
                'success' => false,
                'message' => 'Post tidak ditemukan.',
            ], 404);
        }

        $post->load([
            'user:id,name,avatar',
            'category:id,name,slug,color',
            'tags:id,name,slug',
            'approvedComments.user:id,name,avatar',
        ])->loadCount(['comments', 'likes']);

        $post->increment('views');

        return response()->json([
            'success' => true,
            'data'    => new PostResource($post),
        ]);
    }

    /**
     * POST /api/v1/posts
     */
    public function store(Request $request): JsonResponse
    {
        $this->authorize('create', Post::class);

        $validated = $request->validate([
            'title'       => 'required|string|max:255',
            'content'     => 'required|string|min:100',
            'category_id' => 'required|exists:categories,id',
            'tags'        => 'nullable|array',
            'tags.*'      => 'exists:tags,id',
            'status'      => 'required|in:draft,published',
            'excerpt'     => 'nullable|string|max:500',
        ]);

        $post = DB::transaction(function () use ($request, $validated) {
            $post = Post::create([
                ...$validated,
                'slug'         => \Str::slug($validated['title']) . '-' . time(),
                'user_id'      => $request->user()->id,
                'published_at' => $validated['status'] === 'published' ? now() : null,
            ]);

            $post->tags()->sync($validated['tags'] ?? []);

            return $post;
        });

        return response()->json([
            'success' => true,
            'message' => 'Post berhasil dibuat.',
            'data'    => new PostResource($post->load(['user', 'category', 'tags'])),
        ], 201);
    }

    /**
     * PUT /api/v1/posts/{slug}
     */
    public function update(Request $request, Post $post): JsonResponse
    {
        $this->authorize('update', $post);

        $validated = $request->validate([
            'title'       => 'sometimes|required|string|max:255',
            'content'     => 'sometimes|required|string|min:100',
            'category_id' => 'sometimes|required|exists:categories,id',
            'tags'        => 'nullable|array',
            'tags.*'      => 'exists:tags,id',
            'status'      => 'sometimes|required|in:draft,published,archived',
            'excerpt'     => 'nullable|string|max:500',
        ]);

        $post->update($validated);

        if (isset($validated['tags'])) {
            $post->tags()->sync($validated['tags']);
        }

        return response()->json([
            'success' => true,
            'message' => 'Post berhasil diperbarui.',
            'data'    => new PostResource($post->fresh(['user', 'category', 'tags'])),
        ]);
    }

    /**
     * DELETE /api/v1/posts/{slug}
     */
    public function destroy(Post $post): JsonResponse
    {
        $this->authorize('delete', $post);

        $post->delete();

        return response()->json([
            'success' => true,
            'message' => 'Post berhasil dihapus.',
        ]);
    }

    /**
     * POST /api/v1/posts/{slug}/like — Toggle like
     */
    public function toggleLike(Request $request, Post $post): JsonResponse
    {
        $user   = $request->user();
        $liked  = $post->likes()->where('user_id', $user->id)->exists();

        if ($liked) {
            $post->likes()->where('user_id', $user->id)->delete();
            $message = 'Post di-unlike.';
        } else {
            $post->likes()->create(['user_id' => $user->id]);
            $message = 'Post di-like.';
        }

        return response()->json([
            'success'     => true,
            'message'     => $message,
            'is_liked'    => !$liked,
            'likes_count' => $post->likes()->count(),
        ]);
    }
}

Menangani Error dan Exception di API

Salah satu perbedaan paling penting antara API dan aplikasi web biasa adalah cara menangani error. Di aplikasi web, error menghasilkan halaman HTML. Di API, semua error harus dikembalikan dalam format JSON yang konsisten. Laravel menyediakan cara untuk mengkustomisasi semua error response agar selalu dalam format JSON saat request datang dari API client.

PHP
<?php
// bootstrap/app.php — Kustomisasi exception handling untuk API

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;

return Application::configure(basePath: dirname(__DIR__))
    ->withExceptions(function (Exceptions $exceptions) {

        // Render semua exception sebagai JSON untuk request API
        $exceptions->render(function (\Throwable $e, Request $request) {

            // Hanya tangani jika request mengharapkan JSON
            // (API request biasanya kirim header: Accept: application/json)
            if (!$request->expectsJson()) {
                return null; // Biarkan Laravel handle secara normal
            }

            // ── Validasi gagal ────────────────────────
            if ($e instanceof ValidationException) {
                return response()->json([
                    'success' => false,
                    'message' => 'Data yang dikirim tidak valid.',
                    'errors'  => $e->errors(),
                ], 422);
            }

            // ── Tidak terautentikasi ───────────────────
            if ($e instanceof AuthenticationException) {
                return response()->json([
                    'success' => false,
                    'message' => 'Kamu belum login. Silakan login terlebih dahulu.',
                ], 401);
            }

            // ── Tidak memiliki izin ────────────────────
            if ($e instanceof AuthorizationException) {
                return response()->json([
                    'success' => false,
                    'message' => 'Kamu tidak memiliki izin untuk melakukan aksi ini.',
                ], 403);
            }

            // ── Model tidak ditemukan ──────────────────
            if ($e instanceof ModelNotFoundException) {
                $model = strtolower(class_basename($e->getModel()));
                return response()->json([
                    'success' => false,
                    'message' => ucfirst($model) . ' tidak ditemukan.',
                ], 404);
            }

            // ── Route tidak ditemukan ──────────────────
            if ($e instanceof NotFoundHttpException) {
                return response()->json([
                    'success' => false,
                    'message' => 'Endpoint tidak ditemukan.',
                ], 404);
            }

            // ── HTTP Exception lainnya ─────────────────
            if ($e instanceof HttpException) {
                return response()->json([
                    'success' => false,
                    'message' => $e->getMessage() ?: 'Terjadi kesalahan.',
                ], $e->getStatusCode());
            }

            // ── Server error (production: sembunyikan detail) ──
            if (app()->isProduction()) {
                return response()->json([
                    'success' => false,
                    'message' => 'Terjadi kesalahan pada server. Silakan coba lagi.',
                ], 500);
            }

            // Development: tampilkan detail error
            return response()->json([
                'success'   => false,
                'message'   => $e->getMessage(),
                'exception' => get_class($e),
                'file'      => $e->getFile(),
                'line'      => $e->getLine(),
                'trace'     => $e->getTrace(),
            ], 500);
        });
    })
    ->create();

Menggunakan API dari Client (Contoh Flutter dan JavaScript)

Setelah API selesai dibangun, mari lihat bagaimana cara mengkonsumsinya dari sisi client — baik dari aplikasi Flutter (Dart) maupun JavaScript/fetch API. Memahami perspektif client membantu kamu membangun API yang lebih user-friendly untuk developer yang menggunakannya.

.dart
Text
// ── CONTOH KONSUMSI API DARI FLUTTER (Dart) ───────────
// Menggunakan package http atau dio

import 'dart:convert';
import 'package:http/http.dart' as http;

class ApiService {
  static const String baseUrl = 'https://api.devpath.id/api/v1';

  // Simpan token setelah login (gunakan SharedPreferences / secure storage)
  String? _token;

  // Header standar untuk semua request
  Map<String, String> get _headers => {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
    if (_token != null) 'Authorization': 'Bearer $_token',
  };

  // ── LOGIN ──────────────────────────────────────────
  Future<Map<String, dynamic>> login(String email, String password) async {
    final response = await http.post(
      Uri.parse('$baseUrl/auth/login'),
      headers: _headers,
      body: jsonEncode({'email': email, 'password': password}),
    );

    final data = jsonDecode(response.body);

    if (response.statusCode == 200 && data['success']) {
      _token = data['data']['token'];
      // Simpan token ke SharedPreferences
      return data['data'];
    }

    throw Exception(data['message'] ?? 'Login gagal');
  }

  // ── GET POSTS ──────────────────────────────────────
  Future<Map<String, dynamic>> getPosts({
    int page = 1,
    String? category,
    String? search,
  }) async {
    final queryParams = {
      'page': page.toString(),
      if (category != null) 'category': category,
      if (search != null) 'search': search,
    };

    final uri = Uri.parse('$baseUrl/posts').replace(queryParameters: queryParams);
    final response = await http.get(uri, headers: _headers);
    final data = jsonDecode(response.body);

    if (response.statusCode == 200) return data;
    throw Exception(data['message']);
  }

  // ── CREATE POST ────────────────────────────────────
  Future<Map<String, dynamic>> createPost(Map<String, dynamic> postData) async {
    final response = await http.post(
      Uri.parse('$baseUrl/posts'),
      headers: _headers,
      body: jsonEncode(postData),
    );

    final data = jsonDecode(response.body);
    if (response.statusCode == 201) return data;
    throw Exception(data['message']);
  }
}
JavaScript
// ── CONTOH KONSUMSI API DARI JAVASCRIPT ───────────────

const API_URL = 'https://api.devpath.id/api/v1';

// Simpan token di localStorage
let token = localStorage.getItem('auth_token');

// Helper untuk request dengan token
async function apiRequest(endpoint, options = {}) {
    const headers = {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
    };

    if (token) {
        headers['Authorization'] = `Bearer ${token}`;
    }

    const response = await fetch(`${API_URL}${endpoint}`, {
        ...options,
        headers: { ...headers, ...options.headers },
    });

    const data = await response.json();

    if (!data.success) {
        // Handle 401 — token expired, redirect ke login
        if (response.status === 401) {
            localStorage.removeItem('auth_token');
            window.location.href = '/login';
        }
        throw new Error(data.message);
    }

    return data;
}

// ── LOGIN ──────────────────────────────────────────────
async function login(email, password) {
    const data = await apiRequest('/auth/login', {
        method: 'POST',
        body: JSON.stringify({ email, password }),
    });

    token = data.data.token;
    localStorage.setItem('auth_token', token);
    return data.data.user;
}

// ── GET POSTS ──────────────────────────────────────────
async function getPosts(page = 1, options = {}) {
    const params = new URLSearchParams({ page, ...options });
    const data   = await apiRequest(`/posts?${params}`);

    return {
        posts: data.data,
        meta:  data.meta,
        links: data.links,
    };
}

// ── CREATE POST ────────────────────────────────────────
async function createPost(postData) {
    const data = await apiRequest('/posts', {
        method: 'POST',
        body: JSON.stringify(postData),
    });
    return data.data;
}

// ── LOGOUT ─────────────────────────────────────────────
async function logout() {
    await apiRequest('/auth/logout', { method: 'POST' });
    token = null;
    localStorage.removeItem('auth_token');
}

Token Abilities dan Scopes

Sanctum mendukung token abilities — cara membatasi apa yang bisa dilakukan oleh sebuah token tertentu. Ini berguna ketika kamu ingin membuat token dengan akses terbatas, misalnya token read-only untuk integrasi pihak ketiga, atau token khusus untuk satu fitur tertentu.

PHP
<?php

// ── BUAT TOKEN DENGAN ABILITIES TERTENTU ──────────────
$token = $user->createToken('mobile-app', ['posts:read', 'posts:write']);
$token = $user->createToken('readonly', ['posts:read', 'profile:read']);
$token = $user->createToken('admin-token', ['*']); // Semua abilities

// Dapatkan plain text token untuk dikirim ke client
$plainTextToken = $token->plainTextToken;

// ── CEK ABILITIES DI CONTROLLER ───────────────────────
public function store(Request $request): JsonResponse
{
    // Cek apakah token memiliki ability 'posts:write'
    if (!$request->user()->tokenCan('posts:write')) {
        return response()->json([
            'success' => false,
            'message' => 'Token tidak memiliki izin untuk membuat post.',
        ], 403);
    }

    // ... lanjut buat post
}

// ── MIDDLEWARE ABILITIES ───────────────────────────────
// routes/api.php
Route::middleware(['auth:sanctum', 'abilities:posts:write'])
    ->post('/posts', [PostController::class, 'store']);

// Atau cek salah satu dari beberapa ability:
Route::middleware(['auth:sanctum', 'ability:posts:write,admin'])
    ->post('/posts', [PostController::class, 'store']);

// ── MANAJEMEN TOKEN ────────────────────────────────────
// Lihat semua token milik user
$tokens = $user->tokens()->get();

// Hapus token tertentu
$user->tokens()->where('name', 'mobile-app')->delete();

// Hapus token yang sudah kedaluwarsa
$user->tokens()->where('expires_at', '<', now())->delete();

// Hapus semua token (logout dari semua device)
$user->tokens()->delete();

💡 Untuk production, selalu set expiration pada token agar token yang bocor atau tidak dipakai tidak berlaku selamanya. Gunakan token expiration yang masuk akal sesuai use case: aplikasi mobile biasanya 30-90 hari, sementara integrasi server-to-server bisa lebih panjang tapi harus dirotasi secara berkala. Konfigurasi expiration di config/sanctum.php atau saat membuat token dengan parameter expiresAt.


Langkah Selanjutnya

Luar biasa — kamu sudah menguasai cara membangun REST API dengan Laravel Sanctum secara menyeluruh! Mulai dari instalasi dan konfigurasi Sanctum, merancang struktur endpoint, membuat API Resource untuk format response yang konsisten, Auth Controller yang lengkap, Post Controller dengan filter dan pagination, penanganan error yang proper dalam format JSON, hingga cara mengkonsumsi API dari Flutter dan JavaScript. Di bagian ketiga belas seri ini, kita akan membahas Testing Dasar di Laravel — cara menulis test untuk memastikan aplikasimu bekerja dengan benar dan tidak rusak saat ada perubahan kode.

🚀 Sebelum lanjut ke Bagian 13, pastikan kamu sudah berlatih: (1) menginstal Sanctum dan membuat endpoint login yang menghasilkan token, (2) membuat minimal satu API Resource dan menggunakannya di Controller, (3) menguji semua endpoint menggunakan Postman atau tools serupa — perhatikan cara mengirim token di header Authorization: Bearer {token}, dan (4) mengkustomisasi error response agar semua error dikembalikan dalam format JSON. Jika kamu membangun aplikasi Flutter , API yang dibangun di bagian ini adalah fondasi yang akan digunakan aplikasi mobile untuk berkomunikasi dengan backend Laravel.