← Back to all articles laravel

Building a Production-Ready REST API in Laravel

Most Laravel API tutorials stop at returning a model from a controller. That works until a mobile app hits it, a field gets renamed, an error needs a consistent shape, or somebody asks for pagination. This walks the parts that actually matter once real clients depend on it.

Start with routes that will still make sense later

Put the API on its own file and version it from day one. Adding /v2 later without breaking existing apps is nearly impossible if everything sits at the root.

// routes/api.php
Route::prefix('v1')->group(function () {
    Route::apiResource('projects', ProjectController::class);
    Route::post('login', [AuthController::class, 'login']);
});

apiResource gives you index, store, show, update and destroy without the two form routes a web resource adds.

Never return models directly

Returning Project::all() exposes every column — including anything you add later, which is how internal notes end up in a public response. API Resources put a deliberate shape in between.

class ProjectResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'category' => $this->category,
            'tech_stack' => $this->tech_stack,
            'published_at' => $this->created_at->toIso8601String(),
        ];
    }
}

Now a column rename is a one-line change in the resource instead of a breaking change for every consumer.

Validate in a form request

Validating inside the controller buries the rules and makes them impossible to reuse.

class StoreProjectRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:180'],
            'category' => ['required', 'in:web,api,wordpress,mobile'],
            'tech_stack' => ['array'],
            'tech_stack.*' => ['string', 'max:40'],
        ];
    }
}

Laravel returns a 422 with a field-keyed error object automatically — which is exactly the shape a frontend wants.

Authentication: pick the simpler one

For a first-party app — your own React frontend or mobile client — Sanctum is the right answer. Passport implements full OAuth2 and is worth its complexity only when third parties need to authorise against your API on behalf of users.

public function login(Request $request)
{
    $request->validate(['email' => 'required|email', 'password' => 'required']);

    $user = User::where('email', $request->email)->first();

    if (! $user || ! Hash::check($request->password, $user->password)) {
        return response()->json(['message' => 'Invalid credentials'], 401);
    }

    return response()->json([
        'token' => $user->createToken('api')->plainTextToken,
    ]);
}

Protect routes with auth:sanctum, and give tokens abilities if different clients should not have the same reach.

Paginate before the table grows

An endpoint returning every row is fine with 40 records and a problem with 40,000. Paginate from the start — the response shape changes when you add it, so doing it later breaks clients.

return ProjectResource::collection(
    Project::query()->latest()->paginate($request->integer('per_page', 15))
);

Cap per_page. Without a limit, ?per_page=100000 is a denial-of-service anyone can trigger.

Make every error look the same

A client should never have to parse an HTML error page. Force JSON for API routes:

// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->shouldRenderJsonWhen(
        fn ($request) => $request->is('api/*')
    );
})

Then a missing record returns a JSON 404 rather than a rendered page, and the frontend can handle every failure the same way.

Watch the query count

Returning 15 projects that each load a relation is 16 queries. This is the single most common reason an API feels slow, and it does not show up until the table fills. Eager load what the resource touches:

Project::with('skills')->paginate(15);

I wrote about spotting these in finding and fixing N+1 queries in Eloquent.

Rate limit public endpoints

Route::middleware('throttle:60,1')->group(function () {
    // 60 requests per minute per user or IP
});

Login endpoints deserve something much tighter — five attempts per minute makes credential stuffing impractical.

Before you call it done

  • Versioned routes, so v2 does not break v1
  • Resources between models and responses
  • Form requests holding the validation rules
  • Sanctum tokens, with abilities where they matter
  • Pagination with a capped page size
  • JSON errors for every failure path
  • Eager loading verified by query count, not assumed
  • Rate limits, strictest on auth

None of this is exotic. It is the difference between an endpoint that demos well and one a client's app can depend on.

Found this useful?

I write these from real client projects. Have one that needs building?

Start a project

Keep reading