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
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.
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.
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.
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:
| Code | When we return it |
|---|---|
| 200 | A read or an update that returns the resource |
| 201 | A create, with the new resource in the body |
| 204 | A delete or an action with nothing to return |
| 401 | No valid token or session |
| 403 | Authenticated, but not allowed to do this |
| 404 | The resource doesn't exist, or the user may not know it exists |
| 409 | A conflict with the current state, such as paying an invoice twice |
| 422 | Validation failed |
| 429 | Rate 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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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:
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
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.
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 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
A software project scope lists the goal, the users, the flows with acceptance criteria, what is out, and how changes get handled. Our template inside.

Carlos Saldaña
Oct 6, 2026 · 7 min read