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:

php
// 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:

php
// 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:

php
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:

php
// 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:

php
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.

php
// 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:

bash
php artisan install:api

Protect routes and issue tokens on login:

php
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:

php
// 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:

php
// 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:

bash
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

php
// 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:

bash
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
✨

Topics

#Laravel React developer Nepal#REST API#fintech integration developer#CRM systems#API Design
πŸ’‘

Need Help With This?

Let's talk about how I can architect a scalable solution for your business.

Get In Touch β†’