Building a Clean and Consistent Laravel API Response System

LaravelPHPAPI DevelopmentBackendWeb Security

How to Build a Clean and Consistent Laravel API Response System

When building APIs in Laravel, one of the most important principles is consistency. Clients should always get predictable, structured responses—whether things go right or wrong. In this tutorial, we’ll walk through how to build a reusable API response system and use it in a bulkDelete endpoint example.

Step 1: Create a Base Controller for API Responses

Let’s start with a custom base controller that all your other controllers can extend. This controller provides a consistent structure for success and error responses.

Key Features:

  • Unified format for JSON responses
  • Optional data encryption
  • Standard HTTP status messages
  • Pagination support
class Controller extends BaseController
{
    use AuthorizesRequests, DispatchesJobs, ValidatesRequests;

    protected $errorMessages = [ /* HTTP status messages */ ];

    protected function isEncryptionEnabled(): bool
    {
        return false; // Toggle encryption here
    }

    protected function getRequestData($request): array
    {
        if ($this->isEncryptionEnabled()) {
            $decrypted = app('securityService')->decrypt($request->input, $request->version ?? false);
            return json_decode($decrypted, true) ?? [];
        }

        return $request->all();
    }

    protected function sendSuccess($data = null, $message = null, int $status = 200): JsonResponse
    {
        return $this->sendResponse('success', $status, $message, $data);
    }

    protected function sendError($message = null, int $status = 400): JsonResponse
    {
        if (!isset($this->errorMessages[$status])) $status = 400;
        return $this->sendResponse('error', $status, $message);
    }

    protected function validationError($message, $response = null): JsonResponse
    {
        return $this->sendResponse('error', 422, $message, $response);
    }

    protected function sendResponse(string $status, int $statusCode, $message = null, $data = null): JsonResponse
    {
        $statusMessage = $this->errorMessages[$statusCode] ?? 'Unknown Status';
        $framed = $this->frameResponse($status, $statusCode, $statusMessage, $message, $this->prepareResponseData($data));
        return response()->json($framed, $statusCode);
    }

    protected function frameResponse(string $status, int $statusCode, string $statusMessage, $message = null, $data = null): array
    {
        if ($data instanceof LengthAwarePaginator) {
            $data = $this->formatPaginatedResponse($data);
        }

        return compact('status', 'statusCode', 'statusMessage', 'message', 'data');
    }

    protected function prepareResponseData($data)
    {
        return $this->isEncryptionEnabled() ? app('securityService')->encrypt(json_encode($data)) : $data;
    }

    protected function formatPaginatedResponse($paginator, bool $showLinks = false): array
    {
        $items = $paginator->items();

        $response = [
            'current_page' => (int)$paginator->currentPage(),
            'items' => $items,
            'last_page' => (int)$paginator->lastPage(),
            'per_page' => (int)$paginator->perPage(),
            'from' => (int)$paginator->firstItem(),
            'to' => (int)$paginator->lastItem(),
            'total' => (int)$paginator->total(),
        ];

        if ($showLinks) {
            $response['next_page_url'] = $paginator->nextPageUrl();
            $response['prev_page_url'] = $paginator->previousPageUrl();
        }

        return $response;
    }
}

Step 2: Add a Bulk Delete API Endpoint

With the base response methods in place, implementing consistent endpoints becomes easy.

Here’s an example bulkDelete method that validates input, checks authorization, and uses the unified response system.

public function bulkDelete(Request $request)
{
    $validator = Validator::make($request->all(), [
        'ids' => 'required|array',
        'ids.*' => 'exists:invoices,id',
    ]);

    if ($validator->fails()) {
        return $this->validationError($validator->errors());
    }

    try {
        $unauthorized = Invoice::whereIn('id', $request->ids)
            ->where('user_id', '!=', backpack_user()->id)
            ->exists();

        if ($unauthorized) {
            return $this->sendError('Unauthorized action.', 403);
        }

        $deletedCount = $this->invoiceService->bulkDelete($request->ids);

        return $this->sendSuccess([], "{$deletedCount} invoices deleted.");
    } catch (Exception $e) {
        recordError('Failed to delete invoices', $e);
        return $this->sendError('Failed to delete invoices.', 500);
    }
}

Benefits of This Approach

  • Clarity: Your API always responds in the same JSON format.
  • Maintainability: Changes to the response structure happen in one place.
  • Security: Easily toggle request/response encryption if needed.
  • Professionalism: Clients and frontend developers will appreciate the consistency.

Conclusion

A custom base controller for API responses in Laravel is a small investment that pays off in cleaner, more reliable code. It reduces repetition and ensures your APIs are easier to consume and debug.

Need help expanding this with authentication, filtering, or pagination features? Just ask.