Laravel makes it easy to return JSON from a controller, and that is where many API projects stop thinking about design. A year later the mobile team handles four different error formats, the web app breaks whenever a column is renamed, and nobody is sure which endpoints check permissions. A few conventions set up at the start avoid most of this. The examples below assume Laravel 11 or newer, but the ideas apply to older versions too.
Set Up the API and Version It
Since Laravel 11 a new application does not include API routes by default. Running php artisan install:api creates routes/api.php and installs Laravel Sanctum. Put the routes under a version prefix such as /api/v1 from the first day. Mobile apps stay installed for months without updating, so when a response has to change in a breaking way you can add v2 while old app versions keep working on v1.
Choose the Right Sanctum Mode
| Client | Authentication | Notes |
|---|---|---|
| Mobile app | Sanctum personal access tokens | Issue a token at login, store it in the device keychain or keystore, and give it abilities and an expiry |
| Web frontend on the same top-level domain | Sanctum SPA authentication with cookies | Uses the session cookie and CSRF protection, so no token is stored in JavaScript |
| Another server or partner system | Tokens with limited abilities, or Laravel Passport if partners need OAuth | One token per partner so each one can be revoked on its own |
Avoid storing tokens in browser localStorage. Any script injected into the page can read it. Cookie-based SPA authentication is the safer default for a web frontend.
Validate With Form Requests
Move validation out of the controller into Form Request classes created with php artisan make:request. The rules sit next to the authorize method, controllers stay short, and the same request class documents what the endpoint accepts. When the client sends the Accept: application/json header, failed validation returns a 422 response with an errors object keyed by field name, which frontend and mobile developers can map straight onto form fields. Ask every client to send that header, otherwise Laravel may answer a failed request with a redirect.
Shape Responses With API Resources
Returning Eloquent models directly exposes every column, including ones you add later, and ties the API to the database structure. API Resources (php artisan make:resource) define exactly which fields go out and under which names. You can rename a column without changing the API, hide internal fields, format dates in one place, and include relations only when they were loaded with whenLoaded. Collections of resources also add pagination links and metadata automatically.
Use Status Codes Consistently
| Situation | Status code |
|---|---|
| Read or update succeeded | 200 OK |
| A new record was created | 201 Created |
| Delete succeeded with no body | 204 No Content |
| Not logged in or the token is invalid | 401 Unauthorized |
| Logged in but not allowed to do this | 403 Forbidden |
| The record does not exist | 404 Not Found |
| Validation failed | 422 Unprocessable Content |
| Too many requests | 429 Too Many Requests |
Keep one error shape for everything that is not a validation error, for example a message field and an optional code field that the client can check. In Laravel 11 and later you can customise how exceptions render for API routes in bootstrap/app.php. Make sure stack traces never reach clients by keeping APP_DEBUG off in production.
Authorize Every Endpoint
The most common serious bug in APIs we review is an endpoint that checks the user is logged in but not whether the record belongs to them. Changing the ID in the URL returns another customer's order. Write a Policy for each model and call it in every controller method or Form Request, and add a feature test that requests another user's record and expects a 403 or 404.
Pagination, Filtering, and Performance
- Never return an unbounded list. Use paginate for pages with totals, or cursorPaginate for long feeds and infinite scroll, which stays fast on large tables.
- Set a maximum page size on the server so a client cannot ask for 100,000 rows.
- Load relations with with() and enable Model::preventLazyLoading() outside production, so N+1 queries fail during development.
- Add database indexes for the columns used in filters and sorting.
- Cache responses that are expensive and change rarely, such as reference data and settings.
Rate Limiting
Define limiters with RateLimiter::for in a service provider and attach them with the throttle middleware. Use a strict limit per IP address on login, OTP, and password reset endpoints, and a general limit per user for the rest of the API. Clients receive a 429 response with a Retry-After header when they exceed it, so the mobile app can wait and retry instead of hammering the server.
Document and Test It
- 1Generate an OpenAPI document from the code with a package such as Scramble, so the documentation follows the Form Requests and Resources instead of drifting from them.
- 2Share the OpenAPI file with the frontend and mobile teams so they can generate typed clients.
- 3Write feature tests with getJson and postJson for every endpoint, covering success, validation errors, and access by the wrong user.
- 4Run the tests in CI on every pull request, together with a check that the OpenAPI document still generates.
Key takeaways
- Version the API from the start and use Sanctum tokens for mobile and cookies for web.
- Validate with Form Requests and shape output with API Resources instead of returning models.
- Use consistent status codes and one error format, with debug output off in production.
- Check ownership with Policies on every endpoint and test access by the wrong user.
- Paginate everything, prevent N+1 queries, rate limit sensitive endpoints, and generate OpenAPI docs from the code.


