DEV Community

Cover image for How I Structure Every Laravel REST API Project
Kamruzzaman Polash
Kamruzzaman Polash

Posted on

How I Structure Every Laravel REST API Project

Every time I start a new Laravel API project, I used to spend half a day on the same setup:

  • Sanctum authentication
  • Spatie roles & permissions
  • Consistent JSON error responses
  • API versioning
  • Rate limiting

After doing this on project after project, I finally settled on a structure that works every time. Here's exactly how I do it.


1. Consistent JSON Response Format

The first thing I set up is a trait that forces every endpoint to return the same JSON structure. Nothing is more annoying than an API that returns different formats for success vs errors.

<?php

namespace App\Traits;

trait ApiResponseTrait
{
    public function successResponse($data, $message = 'Success', $statusCode = 200)
    {
        return response()->json([
            'success' => true,
            'message' => $message,
            'data'    => $data,
            'meta'    => [
                'version'   => 'v1',
                'timestamp' => now()->toISOString(),
            ],
        ], $statusCode);
    }

    public function errorResponse($message = 'Error', $statusCode = 400, $errors = [])
    {
        return response()->json([
            'success' => false,
            'message' => $message,
            'errors'  => $errors,
            'meta'    => [
                'version'   => 'v1',
                'timestamp' => now()->toISOString(),
            ],
        ], $statusCode);
    }

    public function notFoundResponse($message = 'Resource not found')
    {
        return $this->errorResponse($message, 404);
    }

    public function unauthorizedResponse($message = 'Unauthorized')
    {
        return $this->errorResponse($message, 401);
    }

    public function validationErrorResponse($errors, $message = 'Validation failed')
    {
        return $this->errorResponse($message, 422, $errors);
    }
}
Enter fullscreen mode Exit fullscreen mode

Every controller uses this trait. No more inconsistent responses.


2. Global Exception Handler

Instead of handling exceptions in every controller, I catch everything in one place.

<?php

namespace App\Exceptions;

use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;

class Handler extends ExceptionHandler
{
    public function render($request, Throwable $exception)
    {
        if ($request->expectsJson()) {

            if ($exception instanceof ModelNotFoundException) {
                return response()->json([
                    'success' => false,
                    'message' => 'Resource not found',
                ], 404);
            }

            if ($exception instanceof AuthenticationException) {
                return response()->json([
                    'success' => false,
                    'message' => 'Unauthenticated',
                ], 401);
            }

            if ($exception instanceof ValidationException) {
                return response()->json([
                    'success' => false,
                    'message' => 'Validation failed',
                    'errors'  => $exception->errors(),
                ], 422);
            }

            if ($exception instanceof NotFoundHttpException) {
                return response()->json([
                    'success' => false,
                    'message' => 'Route not found',
                ], 404);
            }
        }

        return parent::render($request, $exception);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now every exception returns clean JSON automatically — no more ugly HTML error pages in your API.


3. API Versioning Structure

I always separate routes and controllers by version. This way future changes don't break existing clients.

app/Http/Controllers/Api/
├── V1/
│   ├── AuthController.php
│   └── UserController.php
└── V2/  ← ready for the future
Enter fullscreen mode Exit fullscreen mode

In routes/api.php:

use App\Http\Controllers\Api\V1\AuthController;
use App\Http\Controllers\Api\V1\UserController;

Route::prefix('v1')->group(function () {

    // Public auth routes
    Route::prefix('auth')->middleware('throttle:auth')->group(function () {
        Route::post('register', [AuthController::class, 'register']);
        Route::post('login',    [AuthController::class, 'login']);
    });

    // Protected routes
    Route::middleware('auth:sanctum')->group(function () {
        Route::post('auth/logout',  [AuthController::class, 'logout']);
        Route::get('auth/me',       [AuthController::class, 'me']);
        Route::get('users',         [UserController::class, 'index']);
    });

});
Enter fullscreen mode Exit fullscreen mode

Clean, versioned, easy to extend.


4. Sanctum Authentication

I use Laravel Sanctum for all API authentication. Here's a clean AuthController:

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\LoginRequest;
use App\Http\Requests\Api\RegisterRequest;
use App\Models\User;
use App\Traits\ApiResponseTrait;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;

class AuthController extends Controller
{
    use ApiResponseTrait;

    public function register(RegisterRequest $request)
    {
        $user = User::create([
            'name'     => $request->name,
            'email'    => $request->email,
            'password' => Hash::make($request->password),
        ]);

        $user->assignRole('user');

        $token = $user->createToken('api-token')->plainTextToken;

        return $this->successResponse([
            'user'  => $user,
            'token' => $token,
        ], 'Registration successful', 201);
    }

    public function login(LoginRequest $request)
    {
        if (!Auth::attempt($request->only('email', 'password'))) {
            return $this->unauthorizedResponse('Invalid credentials');
        }

        $user  = Auth::user();
        $token = $user->createToken('api-token')->plainTextToken;

        return $this->successResponse([
            'user'  => $user,
            'token' => $token,
        ], 'Login successful');
    }

    public function logout(Request $request)
    {
        $request->user()->currentAccessToken()->delete();
        return $this->successResponse(null, 'Logged out successfully');
    }

    public function me(Request $request)
    {
        return $this->successResponse(
            $request->user()->load('roles', 'permissions')
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Spatie Roles & Permissions

I always use spatie/laravel-permission for roles. Here's my seeder:

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;

class RolePermissionSeeder extends Seeder
{
    public function run(): void
    {
        // Create permissions
        $permissions = [
            'view users',
            'edit users',
            'delete users',
            'manage roles',
        ];

        foreach ($permissions as $permission) {
            Permission::firstOrCreate(['name' => $permission]);
        }

        // Create roles and assign permissions
        $admin = Role::firstOrCreate(['name' => 'admin']);
        $admin->syncPermissions($permissions);

        $editor = Role::firstOrCreate(['name' => 'editor']);
        $editor->syncPermissions(['view users', 'edit users']);

        Role::firstOrCreate(['name' => 'user']);
    }
}
Enter fullscreen mode Exit fullscreen mode

Run php artisan db:seed --class=RolePermissionSeeder and you have a full role system ready.


6. Rate Limiting

In app/Providers/RouteServiceProvider.php:

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

RateLimiter::for('auth', function (Request $request) {
    return Limit::perMinute(10)->by($request->ip());
});
Enter fullscreen mode Exit fullscreen mode

Stricter limits on auth routes to prevent brute force. General API routes get 60 requests per minute.


The Result

Every response from my API looks like this:

{
  "success": true,
  "data": {
    "id": 1,
    "name": "John Doe",
    "email": "john@example.com",
    "roles": ["admin"],
    "permissions": ["view users", "edit users"]
  },
  "meta": {
    "version": "v1",
    "timestamp": "2026-03-05T10:00:00Z"
  }
}
Enter fullscreen mode Exit fullscreen mode

Clean, consistent, predictable. Every endpoint. Every time.


Wrapping Up

This structure took me several projects to settle on. The key principles are:

  • One response format — use a trait, enforce it everywhere
  • One place for exceptions — global handler, not per-controller
  • Version from day one — you'll thank yourself later
  • Roles from day one — adding them later is painful

If you don't want to set all this up manually, I packaged everything into a ready-to-use starter kit — migrations, seeders, controllers, Postman collection, and README all included.

👉 Laravel API Starter Kit


What does your Laravel API structure look like? I'd love to hear how others approach this in the comments.


Top comments (0)