Over 8 years building production Laravel applications in Nepal β fintech portals, CRM systems, ride-sharing backends, sports platforms, job portals β I have learned what separates APIs that stand the test of time from those that collapse under real-world load and team growth.
This guide covers every layer of a production-grade Laravel API. Every pattern here is battle-tested across real projects.
β What Makes an API "Production-Grade"?
- Predictable β every endpoint follows the same conventions
- Type-safe β the API contract is explicit; clients know exactly what shape data will arrive in
- Secure β authentication is enforced, input is validated, errors never leak internal details
- Versioned β breaking changes never break existing clients
- Observable β errors are logged, slow queries are traceable
- Documented β other developers can understand the API without reading source code
β 1. API Resource Classes: Your Contract with the Outside World
The most common mistake I see in Laravel APIs is returning Eloquent models directly:
// DANGEROUS β never do this
public function index(): JsonResponse
{
return response()->json(User::all()); // exposes your raw schema
} This exposes your internal database schema. If you rename a column, add a sensitive field like password or remember_token, or change a data type β every API client breaks.
API Resources create an explicit transformation layer:
// app/Http/Resources/UserResource.php
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'role' => $this->role,
'avatar_url' => $this->avatar ? asset('storage/' . $this->avatar) : null,
'joined_at' => $this->created_at->toISOString(),
// Only include phone for admin requests
'phone' => $this->when($request->user()?->isAdmin(), $this->phone),
];
}
} Now your controller returns a resource, not a raw model:
public function index(): AnonymousResourceCollection
{
$users = User::query()
->select('id', 'name', 'email', 'role', 'avatar', 'created_at')
->paginate(20);
return UserResource::collection($users);
} Laravel's resource collection wraps results in a consistent envelope with pagination metadata β a stable contract that survives any internal refactoring.
β 2. Form Request Validation: Keep Controllers Thin
Controllers should be thin orchestrators. Input validation belongs in dedicated Form Request classes:
// app/Http/Requests/StoreBookingRequest.php
class StoreBookingRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'service_id' => 'required|uuid|exists:services,id',
'scheduled_at' => 'required|date|after:now|before:+3 months',
'notes' => 'nullable|string|max:500',
'phone' => ['required', 'regex:/^+?[0-9]{10,15}$/'],
];
}
public function messages(): array
{
return [
'scheduled_at.after' => 'Booking must be in the future.',
'phone.regex' => 'Phone number must be 10-15 digits.',
];
}
} When this Form Request is type-hinted in the controller, Laravel validates automatically before the method runs:
public function store(StoreBookingRequest $request): BookingResource
{
// At this point, $request is already validated
$booking = Booking::create([
'service_id' => $request->service_id,
'customer_id' => $request->user()->id,
'scheduled_at' => $request->scheduled_at,
'notes' => $request->notes,
'status' => 'pending',
]);
return new BookingResource($booking);
} The controller is 8 lines. Readable, testable, and maintainable.
β 3. API Versioning: Plan for Change from Day One
The most common regret I hear: "We had to update 3 mobile apps, a web app, and two third-party integrations simultaneously when we changed the user object." Versioning prevents this.
// routes/api.php
Route::prefix('v1')->name('api.v1.')->group(function () {
Route::apiResource('users', V1UserController::class);
Route::apiResource('bookings', V1BookingController::class);
});
Route::prefix('v2')->name('api.v2.')->group(function () {
Route::apiResource('users', V2UserController::class);
Route::apiResource('bookings', V2BookingController::class);
}); V1 and V2 controllers share business logic via services. Only the Resource classes differ β V2 may include additional fields without breaking V1 clients.
β 4. Authentication with Laravel Sanctum
Laravel Sanctum handles token-based authentication for SPAs and mobile apps with minimal configuration:
php artisan install:api Protect routes and issue tokens on login:
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn(Request $r) => new UserResource($r->user()));
Route::apiResource('bookings', BookingController::class);
});
// AuthController.php
public function login(LoginRequest $request): JsonResponse
{
if (!Auth::attempt($request->only('email', 'password'))) {
return response()->json([
'error' => 'invalid_credentials',
'message' => 'The email or password is incorrect.',
], 401);
}
$user = Auth::user();
$token = $user->createToken('api-token', ['*'], now()->addDays(30));
return response()->json([
'token' => $token->plainTextToken,
'user' => new UserResource($user),
]);
} For fintech payment endpoints, add rate limiting as an extra safety layer:
// AppServiceProvider or RouteServiceProvider
RateLimiter::for('payment', function (Request $request) {
return Limit::perMinute(10)->by($request->user()?->id ?: $request->ip());
});
Route::middleware(['auth:sanctum', 'throttle:payment'])->group(function () {
Route::post('/payments/initiate', [PaymentController::class, 'initiate']);
Route::post('/payments/verify', [PaymentController::class, 'verify']);
}); This prevents both accidental double-submits and malicious retry attacks on payment endpoints.
β 5. Consistent Error Responses
Nothing frustrates API consumers more than inconsistent error formats. Standardise all error responses in the exception handler:
// app/Exceptions/Handler.php
public function render($request, Throwable $e): Response
{
if ($request->expectsJson() || $request->is('api/*')) {
return match(true) {
$e instanceof ValidationException => response()->json([
'error' => 'validation_failed',
'message' => 'The given data was invalid.',
'errors' => $e->errors(),
], 422),
$e instanceof ModelNotFoundException => response()->json([
'error' => 'not_found',
'message' => class_basename($e->getModel()) . ' not found.',
], 404),
$e instanceof AuthenticationException => response()->json([
'error' => 'unauthenticated',
'message' => 'Authentication required.',
], 401),
default => response()->json([
'error' => 'server_error',
'message' => app()->isProduction()
? 'An unexpected error occurred.'
: $e->getMessage(),
], 500),
};
}
return parent::render($request, $e);
} Every error now follows the same shape: error (machine-readable), message (human-readable), and errors for field-level validation details. Mobile apps and frontends can build reliable error handling on top of this.
β 6. Auto-Generate API Documentation with Scramble
APIs without documentation have a hidden cost β every developer reads source code instead of building features. Scramble reads your Form Request classes, Resource classes, and routes to produce OpenAPI 3.1 specs automatically:
composer require dedoc/scramble Visit /docs/api to see the auto-generated Swagger UI. For fintech and CRM systems where multiple teams consume the API, this is non-negotiable.
β 7. Testing Your API
// tests/Feature/Api/V1/BookingTest.php
class BookingTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_create_booking(): void
{
$user = User::factory()->create();
$service = Service::factory()->available()->create();
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/v1/bookings', [
'service_id' => $service->id,
'scheduled_at' => now()->addDays(2)->toISOString(),
'phone' => '+9779812345678',
]);
$response->assertCreated()
->assertJsonStructure(['data' => ['id', 'service_id', 'scheduled_at', 'status']])
->assertJsonPath('data.status', 'pending');
$this->assertDatabaseHas('bookings', [
'service_id' => $service->id,
'customer_id' => $user->id,
]);
}
public function test_unauthenticated_user_cannot_create_booking(): void
{
$this->postJson('/api/v1/bookings', [])->assertUnauthorized();
}
public function test_validation_fails_for_past_scheduled_at(): void
{
$user = User::factory()->create();
$this->actingAs($user, 'sanctum')
->postJson('/api/v1/bookings', [
'service_id' => Service::factory()->create()->id,
'scheduled_at' => now()->subDay()->toISOString(),
])
->assertUnprocessable()
->assertJsonValidationErrors(['scheduled_at']);
}
} Run tests with coverage before every deployment:
php artisan test --parallel --coverage --min=80 The --parallel flag runs tests simultaneously across CPU cores, cutting test time by 60-80%. The --min=80 flag fails the build if coverage drops below 80%.
β Real Projects This Powers
These patterns run in production across systems I have built in Nepal:
- Nepal Kabaddi League β sports platform serving live scores to thousands of concurrent viewers
- Applyjob Nepal β job portal handling 10,000+ daily API requests from the mobile app
- Ride-sharing backend β driver matching, fare calculation, and real-time location
- Fintech CRM β payment gateway integration with eSewa, Khalti, and Stripe with full audit trails
Need Help With This?
Let's talk about how I can architect a scalable solution for your business.
Get In Touch β