Proper validation and error handling are crucial for API reliability. Laravel provides tools to make both elegant and consistent.
Validation in API Controllers
phppublic function store(Request $request): JsonResponse { $validated = $request->validate([ 'title' => 'required|string|max:255', 'body' => 'required|string', 'category_id' => 'required|exists:categories,id', 'tags' => 'nullable|array', 'tags.*' => 'exists:tags,id', ]); $post = Post::create($validated); return response()->json($post, 201); }
With Accept: application/json, validation errors return:
json{ "message": "The title field is required. (and 1 more error)", "errors": { "title": ["The title field is required."], "body": ["The body field is required."] } }
Form Request Validation
bashphp artisan make:request StorePostRequest
php<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Contracts\Validation\Validator; use Illuminate\Http\Exceptions\HttpResponseException; class StorePostRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'title' => 'required|string|max:255', 'body' => 'required|string|min:100', 'category_id' => 'required|exists:categories,id', 'tags' => 'nullable|array|max:5', 'tags.*' => 'exists:tags,id', 'published_at' => 'nullable|date|after:now', ]; } public function messages(): array { return [ 'title.required' => 'Please provide a title for your post.', 'body.min' => 'Post content must be at least 100 characters.', 'tags.max' => 'You can only select up to 5 tags.', ]; } /** * Customize the failed validation response for APIs. */ protected function failedValidation(Validator $validator): void { throw new HttpResponseException( response()->json([ 'success' => false, 'message' => 'Validation errors', 'errors' => $validator->errors(), ], 422) ); } }
Custom Error Responses
Customize error handling in bootstrap/app.php:
phpuse Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Http\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Illuminate\Validation\ValidationException; use Illuminate\Auth\AuthenticationException; return Application::configure(basePath: dirname(__DIR__)) ->withExceptions(function (Exceptions $exceptions) { // Not Found (404) $exceptions->render(function (NotFoundHttpException $e, Request $request) { if ($request->is('api/*') || $request->wantsJson()) { return response()->json([ 'success' => false, 'message' => 'Resource not found.', ], 404); } }); // Authentication (401) $exceptions->render(function (AuthenticationException $e, Request $request) { if ($request->is('api/*') || $request->wantsJson()) { return response()->json([ 'success' => false, 'message' => 'Unauthenticated.', ], 401); } }); // Validation (422) $exceptions->render(function (ValidationException $e, Request $request) { if ($request->is('api/*') || $request->wantsJson()) { return response()->json([ 'success' => false, 'message' => 'Validation failed.', 'errors' => $e->errors(), ], 422); } }); }) ->create();
Consistent API Responses
Create a response helper:
php// app/Traits/ApiResponses.php <?php namespace App\Traits; use Illuminate\Http\JsonResponse; trait ApiResponses { protected function success($data = null, string $message = 'Success', int $code = 200): JsonResponse { return response()->json([ 'success' => true, 'message' => $message, 'data' => $data, ], $code); } protected function created($data = null, string $message = 'Created successfully'): JsonResponse { return $this->success($data, $message, 201); } protected function noContent(): JsonResponse { return response()->json(null, 204); } protected function error(string $message, int $code = 400, $errors = null): JsonResponse { $response = [ 'success' => false, 'message' => $message, ]; if ($errors) { $response['errors'] = $errors; } return response()->json($response, $code); } protected function notFound(string $message = 'Resource not found'): JsonResponse { return $this->error($message, 404); } protected function unauthorized(string $message = 'Unauthorized'): JsonResponse { return $this->error($message, 401); } protected function forbidden(string $message = 'Forbidden'): JsonResponse { return $this->error($message, 403); } }
Usage:
phpclass PostController extends Controller { use ApiResponses; public function index() { $posts = Post::paginate(15); return $this->success(PostResource::collection($posts)); } public function store(StorePostRequest $request) { $post = Post::create($request->validated()); return $this->created(new PostResource($post)); } public function show(Post $post) { return $this->success(new PostResource($post)); } public function destroy(Post $post) { $post->delete(); return $this->noContent(); } }
Try-Catch for Edge Cases
phppublic function store(StorePostRequest $request): JsonResponse { try { $post = DB::transaction(function () use ($request) { $post = Post::create($request->validated()); $post->tags()->attach($request->tags); return $post; }); return $this->created(new PostResource($post->load('tags'))); } catch (\Exception $e) { Log::error('Post creation failed', [ 'error' => $e->getMessage(), 'user' => auth()->id(), ]); return $this->error('Failed to create post. Please try again.', 500); } }