[ Blog ] → Laravel

API design best practices we use on every Laravel project

The conventions we apply to every Laravel API, from URLs and status codes to errors, versioning, pagination and safe retries, with the code we start from.

Rainer Rodriguez

Co-founder, Nerdify

Oct 13, 2026 · 9 min read

A well-designed API is predictable. Resources have plural noun URLs, status codes mean what HTTP says they mean, every error comes back in one shape, lists are paginated, the version is in the path, and retries can't create duplicates. If a developer can guess the next endpoint from the last one, the design is doing its job.

We have built Laravel APIs since 2016, for our own web and mobile front ends and sometimes for other companies' integrations. The second kind of consumer keeps you honest, because you can't fix their code when your response changes. What follows is the set of conventions we start every new Laravel project with, and the code behind each one.

Design around resources

The first draft of an API tends to mirror the first UI: /dashboard-data, /invoice-page, /get-user-stuff. It works until the mobile app needs the same data in a different shape, and then you have two endpoints that drift apart.

We name endpoints after the things in the domain, use plural nouns, and nest one level at most. Actions that aren't plain CRUD become sub-resources. Paying an invoice creates a payment, so it is a POST to the invoice's payments.

php · routes/api.php
use App\Http\Controllers\Api\V1\InvoiceController;
use App\Http\Controllers\Api\V1\InvoicePaymentController;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')
    ->middleware(['auth:sanctum', 'throttle:api'])
    ->group(function (): void {
        Route::apiResource('invoices', InvoiceController::class);
        Route::post('invoices/{invoice}/payments', [InvoicePaymentController::class, 'store']);
    });

We do break this rule sometimes. When one mobile screen would need six requests on a slow connection, we add an endpoint for that screen and name it after the screen, so nobody mistakes it for a general resource.

Use status codes the way HTTP defines them

Clients branch on status codes, so a 200 with {"success": false} inside forces every consumer to parse the body before it knows what happened. These are the codes we use and when:

CodeWhen we return it
200A read or an update that returns the resource
201A create, with the new resource in the body
204A delete or an action with nothing to return
401No valid token or session
403Authenticated, but not allowed to do this
404The resource doesn't exist, or the user may not know it exists
409A conflict with the current state, such as paying an invoice twice
422Validation failed
429Rate limit hit

The 403 versus 404 line is a judgment call. If a user from one company requests another company's invoice by ID, we return 404, because a 403 confirms that the invoice exists.

Return one error format everywhere

Laravel already returns validation errors as a 422 with a message and an errors object keyed by field. Our React forms parse that shape, so we keep it and make every other error look the same. An API route can still render an HTML error page when the request lacks an Accept: application/json header, and one line in bootstrap/app.php stops that:

php · bootstrap/app.php
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->shouldRenderJsonWhen(
        fn (Request $request): bool => $request->is('api/*') || $request->expectsJson(),
    );
})

RFC 9457 (Problem Details) is a good standard if you are starting from nothing or publishing a public API. We use Laravel's shape because our front ends already speak it. Pick one, write it down, and don't mix them.

Validate at the edge with form requests

Every write goes through a form request. Controllers receive data that is already valid, and the rules live in one place that tests can hit directly.

On every multi-tenant project we apply one more rule. An ID in the request body is input like any other, and it has to belong to the current user's account.

php
namespace App\Http\Requests\Api\V1;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

final class StoreInvoiceRequest extends FormRequest
{
    /**
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            'customer_id' => [
                'required',
                'integer',
                Rule::exists('customers', 'id')->where('team_id', $this->user()->team_id),
            ],
            'due_on' => ['required', 'date', 'after_or_equal:today'],
            'lines' => ['required', 'array', 'min:1'],
            'lines.*.amount_cents' => ['required', 'integer', 'min:1'],
            'lines.*.description' => ['required', 'string', 'max:255'],
        ];
    }
}

Without the where clause, exists:customers,id happily accepts another company's customer.

An ID in the request body is input like any other, and it has to belong to the current user's account.

Shape responses with API resources

We never return Eloquent models straight from a controller. A model's JSON is whatever its columns are today, and the day someone adds a column for internal notes, it ships to every client. An API resource lists the fields on purpose.

php
namespace App\Http\Resources\V1;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

final class InvoiceResource extends JsonResource
{
    /**
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'number' => $this->number,
            'status' => $this->status,
            'total' => [
                'amount_cents' => $this->total_cents,
                'currency' => $this->currency,
            ],
            'due_on' => $this->due_on->toDateString(),
            'created_at' => $this->created_at->toIso8601String(),
            'customer' => CustomerResource::make($this->whenLoaded('customer')),
        ];
    }
}

Money goes out as integer cents with a currency code, never as a float. Dates are ISO 8601. Relations appear only when the controller loaded them, which keeps N+1 queries out of serialization.

Version from the first endpoint

We put v1 in the path on day one, even when there is one client and we wrote it. Path versions are easy to see in logs and easy to route.

The harder question is what forces a v2. We treat removing a field, renaming one, changing its type, or adding a required input as breaking. Adding an optional field or a new endpoint is not. Mobile apps make this stricter, because many people don't update apps, and a version from eight months ago can still be calling your API. We keep the old version running until the analytics say the old app versions are gone, and then we turn it off on a date we announced.

Paginate every list

Every collection endpoint is paginated from the start, including the ones that return twelve rows today. Adding pagination later is a breaking change.

Laravel gives you two options. paginate() returns page numbers and a total, which suits admin tables where people jump to page 7. cursorPaginate() uses an opaque cursor, skips the COUNT(*), and doesn't repeat or skip rows when new records arrive between requests. For feeds and mobile infinite scroll we use cursors.

php
public function index(Request $request): AnonymousResourceCollection
{
    $invoices = $request->user()->team->invoices()
        ->with('customer')
        ->orderByDesc('id')
        ->cursorPaginate(50);

    return InvoiceResource::collection($invoices);
}

The resource collection adds links and meta keys, with the next page's URL in links.next and its cursor in meta.next_cursor, so clients don't build URLs themselves.

Authenticate with Sanctum and rate-limit by user

For our own web front end on the same domain, Sanctum's cookie-based session auth is enough and keeps tokens out of JavaScript. Mobile apps and third-party integrations get personal access tokens with abilities, so a reporting integration can read invoices and nothing else.

Rate limits go in AppServiceProvider and key on the user when there is one:

php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    RateLimiter::for('api', function (Request $request): Limit {
        return Limit::perMinute(120)->by($request->user()?->id ?: $request->ip());
    });
}

Laravel sends Retry-After with the 429, and our clients read it instead of retrying in a tight loop.

Make retries safe

Mobile networks drop responses after the server has done the work. The client retries, and now there are two payments. For any POST that moves money or creates something a person will act on, we accept an Idempotency-Key header and replay the first response for repeats.

php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Date;
use Symfony\Component\HttpFoundation\Response;

final class EnsureIdempotency
{
    public function handle(Request $request, Closure $next): Response
    {
        $key = $request->header('Idempotency-Key');

        if ($key === null || ! $request->isMethod('post')) {
            return $next($request);
        }

        $cacheKey = "idempotency:{$request->user()->id}:{$key}";

        return Cache::lock("{$cacheKey}:lock", 10)->block(5, function () use ($cacheKey, $next, $request): Response {
            $stored = Cache::get($cacheKey);

            if ($stored !== null) {
                return new JsonResponse($stored['body'], $stored['status']);
            }

            $response = $next($request);

            if ($response->isSuccessful()) {
                Cache::put($cacheKey, [
                    'body' => json_decode($response->getContent(), true),
                    'status' => $response->getStatusCode(),
                ], Date::now()->addDay());
            }

            return $response;
        });
    }
}

The lock covers two identical requests that arrive a few milliseconds apart, which is what a double tap on a phone produces. Without it, both requests miss the cache and both run.

Keep the contract in tests and docs

We generate OpenAPI docs from the code with Scramble or Scribe rather than writing YAML by hand, because hand-written specs fall behind quickly. The docs describe the contract. Feature tests enforce it:

php
it('returns invoices in the documented shape', function (): void {
    $user = User::factory()->create();
    Invoice::factory()->for($user->team)->count(3)->create();

    $this->actingAs($user)
        ->getJson('/api/v1/invoices')
        ->assertOk()
        ->assertJsonStructure([
            'data' => ['*' => ['id', 'number', 'status', 'total' => ['amount_cents', 'currency'], 'due_on']],
            'meta' => ['next_cursor'],
        ]);
});

When that test fails after a refactor, someone just made a breaking change, and we find out before a client does.

Read also · Laravel

Database design decisions that save you later

Sep 1, 2026 · 8 min read

[ Read → ]

Because the same rules apply on every project, any of our engineers can open an API they didn't write and know where the routes, rules and resources live. If you want a team that builds Laravel APIs this way, or one to review the API you already have, see our Laravel development services.

Written by

Rainer Rodriguez

Co-founder, Nerdify

Rainer co-founded Nerdify in Managua, Nicaragua, in 2016 and works with the team on the client products it builds and maintains.

Service · Laravel development

Need a team that works like this?

More from the blog

[ All articles → ]
  • Laravel

    Database design decisions that save you later

    The schema decisions we make early on every Laravel project, with the migrations for constraints, types, keys, tenancy, history and live changes.

    Rainer Rodriguez

    Sep 1, 2026 · 8 min read

  • Nearshore

    Nearshore vs. offshore: What changes for your team

    Nearshore teams work in or near your time zone. Offshore teams are far away, often half a day ahead. What that changes for cost, speed and how you work.

    Carlos Saldaña

    Oct 20, 2026 · 7 min read