Skip to content
Muhammet Şafak
tr
Web Development 4 min read

Writing JSON API Endpoints with Laravel

A practical walkthrough of building interface-agnostic JSON API endpoints in Laravel.


When I switched from generating Blade templates to returning JSON in Laravel, the first challenge was: “how do I structure this?” It requires a different way of thinking compared to a classic web app where forms POST to actions and responses are redirects. The client is no longer a browser — it might be a mobile app, another service, or JavaScript code firing AJAX requests.

In this post I’ll walk through how I write JSON API endpoints with Laravel 5, how I organize the response structure, and how I handle the situations I run into most often.

Defining Routes

In Laravel 5, a good habit is to put API routes in their own group inside app/Http/routes.php. It makes it easy to add a URL prefix (/api/v1/...) and to apply middleware cleanly.

Route::group(['prefix' => 'api/v1'], function () {
    Route::get('orders',         'Api\OrderController@index');
    Route::post('orders',        'Api\OrderController@store');
    Route::get('orders/{id}',    'Api\OrderController@show');
    Route::put('orders/{id}',    'Api\OrderController@update');
    Route::delete('orders/{id}', 'Api\OrderController@destroy');
});

I use the Api\ namespace to keep API controllers separate from regular web controllers. Files under app/Http/Controllers/Api/ pick up this prefix.

Returning JSON Responses

You return a JSON response in Laravel with response()->json(). The second parameter sets the status code.

class OrderController extends Controller
{
    public function index()
    {
        $orders = Order::all();

        return response()->json([
            'data' => $orders,
        ], 200);
    }

    public function store(Request $request)
    {
        $order = Order::create($request->only('product_id', 'quantity'));

        return response()->json([
            'data' => $order,
        ], 201);
    }

    public function show($id)
    {
        $order = Order::find($id);

        if (!$order) {
            return response()->json([
                'message' => 'Order not found.',
            ], 404);
        }

        return response()->json([
            'data' => $order,
        ], 200);
    }
}

One thing I’m deliberate about: always using a data key in every response. At first I thought “why add this extra layer?” but once the client side has a consistent response structure, a clear contract emerges — “if data is my payload then errors live under a separate key.” It makes consumption predictable.

Standardizing Error Responses

Returning error responses in a single, consistent format across the whole application makes life easier for whoever is writing the client. A single helper method does the job:

protected function errorResponse($message, $code = 400): \Illuminate\Http\JsonResponse
{
    return response()->json([
        'error'   => true,
        'message' => $message,
    ], $code);
}

Then inside any controller: return $this->errorResponse('Invalid data.', 422);. No inconsistent formats scattered across controllers.

Moving this into a dedicated trait rather than a base controller can be cleaner. That way any controller that needs the behavior can pull it in without being forced into a particular inheritance hierarchy.

Validation and APIs

Laravel’s validation system normally redirects on failure. In an API we want a JSON error response, not a redirect.

The FormRequest class handles this. Create a StoreOrderRequest and drop the wantsJson() check — Laravel 5 automatically returns a 422 JSON error response for JSON requests.

class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'product_id' => 'required|integer|exists:products,id',
            'quantity'   => 'required|integer|min:1',
        ];
    }
}

Type-hinting it in the controller is all you need:

public function store(StoreOrderRequest $request)
{
    // If we reach this point, validation has passed
    $order = Order::create($request->only('product_id', 'quantity'));

    return response()->json(['data' => $order], 201);
}

When validation fails, Laravel automatically produces a response like this:

{
  "product_id": ["The product field is required."],
  "quantity": ["The quantity field is required."]
}

with a 422 status code.

Setting the Content-Type Header Correctly

On the client side, don’t forget to include Content-Type: application/json when sending JSON. Laravel looks at this header to parse the request body as JSON. Without it, $request->input() won’t reach your values.

If the header is missing, Laravel treats the request as form data. You send a JSON body, but $request->input('product_id') returns null — and it takes a while to figure out why. When testing with Postman, always set both Content-Type: application/json and Accept: application/json. The second one tells Laravel to return validation errors as JSON; leave it out and you might get an HTML error page instead.

My First Real API Experience

When I built a small order API this way and connected it to a simple iOS app, it felt remarkably clean. The server side just returns data — it doesn’t need to know how it will be displayed. The client side just receives and renders data — it doesn’t need to know how it was produced.

That separation pays off as the application grows. A web interface, a mobile app, and another service can all consume the same API. You write the logic once.

Tags: #Laravel#API
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind