Validasi, Error Handling, dan API Resource - Perwira Learning Center

 

1. LATAR BELAKANG

    Setelah berhasil membangun API dengan routing, controller, dan koneksi database yang solid, tantangan berikutnya adalah bagaimana memastikan API kita profesional dan mudah digunakan oleh developer lain. API yang baik bukan hanya sekadar berfungsi, tetapi juga harus robust (tahan terhadap input tidak valid), informatif (memberi pesan error yang jelas), dan konsisten (format response yang seragam).

Pada hari keempat minggu ini, saya mempelajari tiga komponen penting yang membuat API kita naik kelas:

  1. Validasi Request - Memastikan data yang masuk sesuai aturan sebelum diproses
  2. Error Handling - Menangani kesalahan dengan pesan yang jelas dan HTTP status code yang tepat
  3. API Resource - Mentransformasi data model menjadi response JSON yang terstruktur dan konsisten

Ketiga komponen ini adalah ciri khas API profesional yang siap digunakan oleh frontend developer atau tim mobile.


2. ALAT DAN BAHAN

2.1 Perangkat Lunak

  • Laravel 11 - Project API yang sudah berjalan (dari artikel sebelumnya)
  • Postman/Thunder Client - Untuk testing endpoint dan melihat response error
  • Visual Studio Code - Dengan ekstensi Laravel
  • Git Bash/Terminal - Untuk menjalankan perintah Artisan

2.2 Perangkat Keras

  • Laptop dengan spesifikasi standar
  • Koneksi internet (untuk dokumentasi)


3. PEMBAHASAN

3.1 Validasi Request di Laravel

Validasi adalah gerbang pertama pertahanan API kita. Sebelum data masuk ke database, kita harus memastikan data tersebut sesuai dengan aturan bisnis yang ditetapkan.

3.1.1 Validasi Langsung di Controller

Cara paling sederhana adalah melakukan validasi langsung di method controller:

php
<?php
// File: app/Http/Controllers/Api/PostController.php

public function store(Request $request)
{
// Validasi manual di controller
$validated = $request->validate([
'title' => 'required|max:255',
'content' => 'required|min:10',
'category_id' => 'required|exists:categories,id',
'image' => 'nullable|url',
'is_published' => 'boolean'
]);

$post = Post::create($validated);
return response()->json([
'success' => true,
'message' => 'Post berhasil dibuat',
'data' => $post
], 201);
}

Keuntungan:

  • Cepat dan sederhana untuk kasus sederhana

Kekurangan:

  • Kode menjadi panjang jika banyak method
  • Aturan validasi tidak bisa dipakai ulang
  • Controller menjadi gemuk (fat controller)

3.1.2 Response Error Validasi Default

Jika validasi gagal, Laravel secara otomatis mengembalikan response:

json
{
"message": "The given data was invalid.",
"errors": {
"title": ["The title field is required."],
"content": ["The content field is required."]
}
}

Status code: 422 Unprocessable Entity

3.1.3 Form Request Validation (Best Practice)

Ini adalah cara yang direkomendasikan untuk API profesional. Kita pisahkan logic validasi ke class terpisah.

bash
# Membuat Form Request
php artisan make:request StorePostRequest
php artisan make:request UpdatePostRequest

Struktur Form Request:

php
<?php
// File: app/Http/Requests/StorePostRequest.php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;

class StorePostRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// Cek apakah user boleh membuat post
// Nanti akan dihandle dengan auth
return true;
}

/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'title' => 'required|string|max:255|unique:posts,title',
'content' => 'required|string|min:10',
'category_id' => 'required|integer|exists:categories,id',
'image' => 'nullable|url|ends_with:.jpg,.jpeg,.png',
'tags' => 'nullable|array',
'tags.*' => 'integer|exists:tags,id',
'is_published' => 'boolean',
'published_at' => 'nullable|date|after:now'
];
}

/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'title.required' => 'Judul post wajib diisi',
'title.unique' => 'Judul post sudah digunakan',
'content.required' => 'Konten post wajib diisi',
'content.min' => 'Konten minimal 10 karakter',
'category_id.required' => 'Kategori wajib dipilih',
'category_id.exists' => 'Kategori tidak ditemukan',
'image.url' => 'Format gambar harus URL yang valid',
'image.ends_with' => 'Gambar harus berformat JPG, JPEG, atau PNG',
'published_at.after' => 'Tanggal publish harus setelah sekarang'
];
}

/**
* Get custom attributes for validator errors.
*/
public function attributes(): array
{
return [
'title' => 'judul',
'content' => 'konten',
'category_id' => 'kategori',
'published_at' => 'tanggal publish'
];
}

/**
* Handle a failed validation attempt.
*/
protected function failedValidation(Validator $validator)
{
throw new HttpResponseException(
response()->json([
'success' => false,
'message' => 'Validasi gagal',
'errors' => $validator->errors()
], 422)
);
}

/**
* Prepare the data for validation.
*/
protected function prepareForValidation()
{
// Memodifikasi data sebelum validasi
$this->merge([
'title' => trim($this->title),
'slug' => \Str::slug($this->title)
]);
}
}

Penggunaan di Controller:

php
<?php
// File: app/Http/Controllers/Api/PostController.php

use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;

class PostController extends Controller
{
public function store(StorePostRequest $request)
{
// Data sudah tervalidasi otomatis
$validated = $request->validated();
// Tambahkan user_id (nanti dari auth)
$validated['user_id'] = auth()->id();
$post = Post::create($validated);
// Handle tags jika ada
if ($request->has('tags')) {
$post->tags()->sync($request->tags);
}
return response()->json([
'success' => true,
'message' => 'Post berhasil dibuat',
'data' => $post->load(['category', 'tags'])
], 201);
}
public function update(UpdatePostRequest $request, Post $post)
{
// Cek kepemilikan (nanti dengan Policy)
if ($post->user_id !== auth()->id()) {
return response()->json([
'success' => false,
'message' => 'Anda tidak berhak mengupdate post ini'
], 403);
}
$validated = $request->validated();
$post->update($validated);
return response()->json([
'success' => true,
'message' => 'Post berhasil diupdate',
'data' => $post
]);
}
}

3.2 Aturan Validasi Lanjutan

3.2.1 Custom Validation Rule

Untuk aturan yang kompleks, kita bisa membuat rule kustom:

bash
php artisan make:rule SlugRule
php
<?php
// File: app/Rules/SlugRule.php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class SlugRule implements ValidationRule
{
/**
* Run the validation rule.
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// Slug hanya boleh huruf kecil, angka, dan tanda hubung
if (!preg_match('/^[a-z0-9-]+$/', $value)) {
$fail('Slug hanya boleh berisi huruf kecil, angka, dan tanda hubung.');
}
// Slug tidak boleh diakhiri dengan tanda hubung
if (str_ends_with($value, '-')) {
$fail('Slug tidak boleh diakhiri dengan tanda hubung.');
}
}
}

Penggunaan:

php
' slug' => ['required', new SlugRule, 'unique:posts,slug']
3.2.2 Conditional Validation

Validasi bersyarat berdasarkan nilai field lain:

php
public function rules(): array
{
return [
'is_published' => 'boolean',
'published_at' => 'required_if:is_published,true|date|after:now',
'email' => 'required_with:send_notification|email',
'discount' => 'required_unless:price,0|numeric|min:0|max:100'
];
}

3.3 Error Handling Profesional

API yang baik harus memberikan pesan error yang jelas dan konsisten.

3.3.1 Exception Handler Kustom

Laravel menyediakan file App\Exceptions\Handler.php untuk menangani semua exception.

php
<?php
// File: app/Exceptions/Handler.php

namespace App\Exceptions;

use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Throwable;

class Handler extends ExceptionHandler
{
public function render($request, Throwable $exception)
{
// Jika request mengharapkan JSON (API)
if ($request->expectsJson()) {
return $this->handleApiException($request, $exception);
}
return parent::render($request, $exception);
}
protected function handleApiException($request, Throwable $exception)
{
// Model not found (404)
if ($exception instanceof ModelNotFoundException) {
return response()->json([
'success' => false,
'message' => 'Data tidak ditemukan',
'errors' => null
], 404);
}
// Route not found (404)
if ($exception instanceof NotFoundHttpException) {
return response()->json([
'success' => false,
'message' => 'Endpoint tidak ditemukan',
'errors' => null
], 404);
}
// Method not allowed (405)
if ($exception instanceof MethodNotAllowedHttpException) {
return response()->json([
'success' => false,
'message' => 'Method HTTP tidak diizinkan',
'errors' => null
], 405);
}
// Validation exception (422) - sudah ditangani Laravel
if ($exception instanceof ValidationException) {
return response()->json([
'success' => false,
'message' => 'Validasi gagal',
'errors' => $exception->errors()
], 422);
}
// Query exception (database error)
if ($exception instanceof \Illuminate\Database\QueryException) {
// Log error untuk debugging
\Log::error('Database Error: ' . $exception->getMessage());
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan database',
'errors' => null
], 500);
}
// Default error (500)
return response()->json([
'success' => false,
'message' => 'Terjadi kesalahan server: ' . $exception->getMessage(),
'errors' => null
], 500);
}
}
3.3.2 Custom Exception

Untuk skenario spesifik, kita bisa membuat exception kustom:

bash
php artisan make:exception UnauthorizedActionException
php
<?php
// File: app/Exceptions/UnauthorizedActionException.php

namespace App\Exceptions;

use Exception;

class UnauthorizedActionException extends Exception
{
protected $message;
protected $code;
public function __construct($message = "Aksi tidak diizinkan", $code = 403)
{
$this->message = $message;
$this->code = $code;
parent::__construct($message, $code);
}
public function render($request)
{
if ($request->expectsJson()) {
return response()->json([
'success' => false,
'message' => $this->message,
'errors' => null
], $this->code);
}
return parent::render($request);
}
}

Penggunaan:

php
use App\Exceptions\UnauthorizedActionException;

public function update(UpdatePostRequest $request, Post $post)
{
if ($post->user_id !== auth()->id()) {
throw new UnauthorizedActionException('Anda tidak berhak mengupdate post ini');
}
// ... lanjut update
}

3.4 API Resource (Data Transformation)

API Resource adalah layer transformasi yang mengubah model Eloquent menjadi JSON yang siap dikirim ke client. Ini memastikan response kita konsisten di seluruh endpoint.

3.4.1 Membuat API Resource
bash
# Membuat resource untuk single Post
php artisan make:resource PostResource

# Membuat resource untuk collection Post
php artisan make:resource PostCollection

# Atau buat sekaligus dengan opsi --collection
php artisan make:resource PostResource --collection
3.4.2 Struktur API Resource
php
<?php
// File: app/Http/Resources/PostResource.php

namespace App\Http\Resources;

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

class PostResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'content' => $this->content,
'excerpt' => substr($this->content, 0, 100) . '...',
'image' => $this->image,
'views' => $this->views,
'is_published' => $this->is_published,
'published_at' => $this->published_at ? $this->published_at->format('d M Y') : null,
'created_at' => $this->created_at->diffForHumans(),
'updated_at' => $this->updated_at->format('Y-m-d H:i:s'),
// Relasi
'category' => new CategoryResource($this->whenLoaded('category')),
'author' => new UserResource($this->whenLoaded('author')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
// Hitungan (counts)
'comments_count' => $this->whenCounted('comments'),
'likes_count' => $this->whenCounted('likes'),
// URL API
'links' => [
'self' => route('api.posts.show', $this->id),
'comments' => route('api.posts.comments', $this->id)
]
];
}
/**
* Customize the response for the resource.
*/
public function with(Request $request): array
{
return [
'success' => true,
'message' => 'Data post berhasil diambil',
'meta' => [
'version' => '1.0.0',
'api_docs' => url('/api/docs')
]
];
}
/**
* Customize the response status code.
*/
public function withResponse($request, $response)
{
$response->setStatusCode(200);
}
}
3.4.3 Resource untuk Collection
php
<?php
// File: app/Http/Resources/PostCollection.php

namespace App\Http\Resources;

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

class PostCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*/
public function toArray(Request $request): array
{
return [
'data' => PostResource::collection($this->collection),
'meta' => [
'total' => $this->total(),
'count' => $this->count(),
'per_page' => $this->perPage(),
'current_page' => $this->currentPage(),
'last_page' => $this->lastPage(),
'from' => $this->firstItem(),
'to' => $this->lastItem(),
],
'links' => [
'first' => $this->url(1),
'last' => $this->url($this->lastPage()),
'prev' => $this->previousPageUrl(),
'next' => $this->nextPageUrl(),
],
];
}
/**
* Customize the response for the collection.
*/
public function with(Request $request): array
{
return [
'success' => true,
'message' => 'Daftar post berhasil diambil'
];
}
}
3.4.4 Resource untuk Relasi
php
<?php
// File: app/Http/Resources/CategoryResource.php

namespace App\Http\Resources;

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

class CategoryResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
'posts_count' => $this->whenCounted('posts'),
'created_at' => $this->created_at->format('Y-m-d')
];
}
}
php
<?php
// File: 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,
'username' => $this->username,
'email' => $this->email,
'avatar' => $this->avatar ? url($this->avatar) : null,
'role' => $this->role,
'joined_at' => $this->created_at->diffForHumans(),
'is_verified' => !is_null($this->email_verified_at)
];
}
}
3.4.5 Penggunaan di Controller
php
<?php
// File: app/Http/Controllers/Api/PostController.php

use App\Http\Resources\PostResource;
use App\Http\Resources\PostCollection;
use App\Http\Requests\StorePostRequest;

class PostController extends Controller
{
/**
* GET /api/posts
*/
public function index(Request $request)
{
$posts = Post::with(['category', 'author', 'tags'])
->withCount(['comments', 'likes'])
->published()
->paginate(15);
// Menggunakan PostCollection
return new PostCollection($posts);
}
/**
* GET /api/posts/{id}
*/
public function show(Post $post)
{
$post->load(['category', 'author', 'tags', 'comments.user'])
->loadCount(['comments', 'likes']);
// Menggunakan PostResource
return new PostResource($post);
}
/**
* POST /api/posts
*/
public function store(StorePostRequest $request)
{
$post = Post::create($request->validated() + [
'user_id' => auth()->id(),
'slug' => \Str::slug($request->title)
]);
if ($request->has('tags')) {
$post->tags()->sync($request->tags);
}
return (new PostResource($post))
->additional([
'success' => true,
'message' => 'Post berhasil dibuat'
])
->response()
->setStatusCode(201);
}
/**
* GET /api/posts/featured
*/
public function featured()
{
$posts = Post::with(['category', 'author'])
->where('views', '>', 1000)
->latest()
->take(5)
->get();
// Bisa juga menggunakan collection biasa
return PostResource::collection($posts);
}
}

3.5 API Response Trait (Best Practice)

Untuk konsistensi maksimal, kita bisa membuat Trait khusus untuk response:

php
<?php
// File: app/Traits/ApiResponseTrait.php

namespace App\Traits;

trait ApiResponseTrait
{
/**
* Success response
*/
protected function successResponse($data = null, $message = 'Berhasil', $code = 200)
{
return response()->json([
'success' => true,
'message' => $message,
'data' => $data
], $code);
}
/**
* Error response
*/
protected function errorResponse($message = 'Terjadi kesalahan', $code = 400, $errors = null)
{
return response()->json([
'success' => false,
'message' => $message,
'errors' => $errors
], $code);
}
/**
* Created response (201)
*/
protected function createdResponse($data = null, $message = 'Data berhasil dibuat')
{
return $this->successResponse($data, $message, 201);
}
/**
* Deleted response (200)
*/
protected function deletedResponse($message = 'Data berhasil dihapus')
{
return $this->successResponse(null, $message);
}
/**
* Not found response (404)
*/
protected function notFoundResponse($message = 'Data tidak ditemukan')
{
return $this->errorResponse($message, 404);
}
/**
* Validation error response (422)
*/
protected function validationErrorResponse($errors, $message = 'Validasi gagal')
{
return response()->json([
'success' => false,
'message' => $message,
'errors' => $errors
], 422);
}
}

Penggunaan di Controller:

php
<?php
// File: app/Http/Controllers/Api/PostController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Traits\ApiResponseTrait;
use App\Models\Post;
use App\Http\Resources\PostResource;

class PostController extends Controller
{
use ApiResponseTrait;
public function index()
{
$posts = Post::paginate(15);
return $this->successResponse(
PostResource::collection($posts),
'Daftar post berhasil diambil'
);
}
public function show($id)
{
try {
$post = Post::findOrFail($id);
return $this->successResponse(
new PostResource($post),
'Detail post berhasil diambil'
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return $this->notFoundResponse('Post tidak ditemukan');
}
}
public function destroy($id)
{
try {
$post = Post::findOrFail($id);
// Cek kepemilikan
if ($post->user_id !== auth()->id()) {
return $this->errorResponse('Anda tidak berhak menghapus post ini', 403);
}
$post->delete();
return $this->deletedResponse('Post berhasil dihapus');
} catch (\Exception $e) {
return $this->errorResponse('Terjadi kesalahan saat menghapus post', 500);
}
}
}

3.6 Testing Semua Skenario dengan Postman

3.6.1 Test Validasi Error

Request:

text
POST /api/posts
Content-Type: application/json

{ "title": "", "content": "short"
}

Response (422):

json
{
"success": false,
"message": "Validasi gagal",
"errors": {
"title": ["Judul post wajib diisi"],
"content": ["Konten minimal 10 karakter"],
"category_id": ["Kategori wajib dipilih"]
}
}
3.6.2 Test Not Found Error

Request:

text
GET /api/posts/9999

Response (404):

json
{
"success": false,
"message": "Data tidak ditemukan",
"errors": null
}
3.6.3 Test Success Response

Request:

text
GET /api/posts/1

Response (200):

json
{
"success": true,
"message": "Detail post berhasil diambil",
"data": {
"id": 1,
"title": "Belajar Laravel API",
"slug": "belajar-laravel-api",
"content": "Lorem ipsum...",
"excerpt": "Lorem ipsum...",
"views": 150,
"created_at": "2 days ago",
"category": {
"id": 1,
"name": "Programming",
"slug": "programming"
},
"author": {
"id": 1,
"name": "John Doe",
"username": "johndoe"
},
"comments_count": 5,
"links": {
"self": "http://localhost:8000/api/posts/1",
"comments": "http://localhost:8000/api/posts/1/comments"
}
}
}

3.7 Kendala dan Solusi

Kendala yang Dihadapi:

  1. Validasi berulang di banyak tempat
    • Solusi: Gunakan Form Request untuk reusability
  2. Response tidak konsisten antar endpoint

    • Solusi: Buat API Resource dan ApiResponseTrait
  3. Pesan error tidak informatif

    • Solusi: Kustomisasi message di Form Request dan Exception Handler
  4. Debugging error di production

    • Solusi: Log error dengan \Log::error() dan tampilkan pesan umum ke user
  5. Over-fetching data (mengirim terlalu banyak field)

    • Solusi: Gunakan API Resource untuk memilih field yang dikirim


4. KESIMPULAN

Tiga komponen yang dipelajari hari ini adalah fondasi API :

  1. Form Request Validation
    • Memisahkan logic validasi dari controller
    • Aturan validasi bisa dipakai ulang
    • Pesan error bisa dikustomisasi
  2. Error Handling Terpusat

    • Exception Handler untuk menangani semua error
    • HTTP status code yang tepat untuk setiap skenario
    • Response error yang konsisten
  3. API Resource

    • Transformasi data model ke JSON
    • Kontrol penuh atas struktur response
    • Menghindari over-fetching data
    • Konsistensi antar endpoint

Dengan ketiga komponen ini, API kita sekarang:

  • Robust - Tahan terhadap input tidak valid
  • Informatif - Memberi pesan error yang jelas
  • Konsisten - Format response seragam
  • Profesional - Siap digunakan oleh tim frontend/mobile

Pada artikel selanjutnya, kita akan mempelajari Authentication dengan Laravel Sanctum untuk melindungi endpoint dan mengelola user.


5. DAFTAR PUSTAKA

  1. Laravel Official Documentation. (n.d.). Laravel 12.x Documentation Error Handlinghttps://laravel.com/docs/12.x/errors
  2. Laravel Official Documentation. (n.d.). Laravel 12.x Documentation - Validationhttps://laravel.com/docs/12.x/validation#main-content
  3. Laravel Official Documentation. (n.d.). Laravel 12.x Documentation - Eloquent: API Resourceshttps://laravel.com/docs/12.x/eloquent-resources#main-content
  4. Laravel Official Documentation. (n.d.). Laravel 12.x Documentation - Form Request Validationhttps://laravel.com/docs/12.x/validation#form-request-validation

Posting Komentar

0 Komentar