// back_to_blog Laravel

Building RESTful APIs with Laravel the Way I Ship Them

Jul 09, 2026 · 3 min read · 4 tags
Building RESTful APIs with Laravel the Way I Ship Them

API-first from day one

When Flutter (or any mobile client) is in the plan, I treat the Laravel API as a product surface—not a leftover from a Blade app. Responses are JSON, auth is token-based with Sanctum, and validation errors are predictable.

Step 1 — Project + Sanctum

composer create-project laravel/laravel api-app
cd api-app
composer require laravel/sanctum
php artisan install:api
php artisan migrate

Confirm the User model uses Laravel\Sanctum\HasApiTokens.

Step 2 — Routes

Route::prefix('v1')->group(function () {
    Route::post('/register', [AuthController::class, 'register']);
    Route::post('/login', [AuthController::class, 'login']);

    Route::middleware('auth:sanctum')->group(function () {
        Route::post('/logout', [AuthController::class, 'logout']);
        Route::apiResource('tasks', TaskController::class);
    });
});

Step 3 — Form Request validation

php artisan make:request StoreTaskRequest
public function rules(): array
{
    return [
        'title' => ['required', 'string', 'max:255'],
        'done' => ['sometimes', 'boolean'],
    ];
}

Failed validation returns 422 with an errors object—exactly what mobile clients should parse.

Step 4 — API Resource

php artisan make:resource TaskResource
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'title' => $this->title,
        'done' => (bool) $this->done,
        'created_at' => $this->created_at?->toIso8601String(),
    ];
}

Eloquent models are not your public contract. Resources keep the mobile shape stable while the schema evolves.

Step 5 — Controller pattern

public function store(StoreTaskRequest $request)
{
    $task = $request->user()->tasks()->create($request->validated());

    return (new TaskResource($task))
        ->response()
        ->setStatusCode(201);
}

public function index(Request $request)
{
    $tasks = $request->user()->tasks()->latest()->paginate(20);

    return TaskResource::collection($tasks);
}

Step 6 — Authorization with policies

php artisan make:policy TaskPolicy --model=Task
// Gate::authorize('update', $task); or $this->authorize('update', $task);

Return clear 403 JSON rather than leaking whether a row exists when that matters for your threat model.

Step 7 — Consistent errors clients can handle

  • 401 — missing/invalid token → Flutter forces re-login
  • 403 — authenticated but not allowed
  • 422 — field errors
  • 429 — rate limited (add throttle middleware on auth routes)

Versioning and pagination

Prefix v1 from day one. Paginate lists. Document Authorization: Bearer … and Accept: application/json. That is how I ship Laravel APIs that Flutter can consume without drama.

Logout and token hygiene

public function logout(Request $request)
{
    $request->user()->currentAccessToken()->delete();
    return response()->json(['message' => 'Logged out']);
}

For mobile, decide whether one device shares one token or each install gets its own named token (createToken('android-pixel')). On password change, revoke other tokens.

Rate limiting auth routes

Route::post('/login', [AuthController::class, 'login'])
    ->middleware('throttle:5,1');

Brute force against /login is not theoretical. Throttle early.

CORS for local Flutter web / separate domains

If the client origin differs from the API host, configure Laravel’s CORS middleware carefully. For native mobile talking HTTPS to your API, CORS is usually irrelevant; for Flutter web it matters.

Testing the API without the app

  1. Register/login via HTTP client (Insomnia/Postman/curl).
  2. Copy the Bearer token.
  3. Hit a protected route with Authorization: Bearer … and Accept: application/json.
  4. Assert 422 by omitting a required field before you write Flutter forms.
curl -X POST https://api.example.test/api/v1/login \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{"email":"a@b.com","password":"secret"}'

Common mistakes

  • Returning Eloquent models directly and leaking attributes later
  • Forgetting Accept: application/json and getting HTML error pages in the client
  • No pagination on list endpoints
  • Authorizing in the UI only (never skip policies on the server)

Idempotency and destructive actions

DELETE and PATCH should be safe to retry where possible. For payments or one-time actions, use idempotency keys or server-side guards so mobile retries do not double-charge.

Enjoyed this article?

Explore more posts or get in touch about a project.