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.