Skip to content

Meeting 17 – API Development in Laravel

In this session we focus on building robust, consistent, scalable RESTful APIs with Laravel. We build on prior meetings (validation, auth, database) and now combine them into API architecture patterns.

API Flow


1. API Design Goals

A good API is: Consistent, Predictable, Discoverable, Secure, Performant, Evolvable.

Key attributes:

  • Consistency: uniform naming, status codes, envelopes.
  • Predictability: same input → same output shape; errors standardized.
  • Evolvability: versioning strategy to avoid breaking clients.
  • Observability: logs, traces, metrics available.
  • Least Coupling: clients not tied to internal DB schemas.

2. REST Resource Modeling

Identify nouns (resources) and their relationships.

  • Users, Posts, Comments, Tags.
  • Relationships define nested routes or filters (e.g. /posts/{post}/comments).

Guidelines:

  • Plural resource names: /users, /posts/{id}.
  • Use IDs in path, queries for filtering: /posts?author_id=5&tag=laravel.
  • Avoid verbs in endpoints (/createPost)—use HTTP methods.

3. HTTP Methods & Semantics

MethodTypical UseSafeIdempotentPartial Update
GETRetrieveYesYesN/A
POSTCreate / ActionNoNoYes (custom)
PUTReplaceNoYesNo
PATCHPartial UpdateNoNot guaranteed*Yes
DELETERemoveNoYesN/A

PATCH idempotency depends on implementation—design so sending same patch twice yields same result when possible.


4. Versioning Strategy

Options:

  • URI: /api/v1/posts (simple, explicit).
  • Header: Accept: application/vnd.myapp.v1+json (clean URLs but hidden complexity).
  • No version (YAGNI) until contract stabilizes.

Guidelines:

  • Start with URI versioning for clarity.
  • Bump major version only for backward incompatible changes.
  • Support overlap window; deprecate with Deprecation and Sunset headers.

5. Routing & Grouping

routes/api.php is stateless and automatically gets api middleware group (rate limiting, etc.).

php
// routes/api.php
Route::prefix('v1')->group(function () {
    Route::apiResource('posts', PostController::class);
    Route::apiResource('posts.comments', PostCommentController::class)->shallow();

    Route::get('me', MeController::class)->middleware('auth:sanctum');
});
// routes/api.php
Route::prefix('v1')->group(function () {
    Route::apiResource('posts', PostController::class);
    Route::apiResource('posts.comments', PostCommentController::class)->shallow();

    Route::get('me', MeController::class)->middleware('auth:sanctum');
});

Use apiResource() for standard CRUD (index, show, store, update, destroy). Use shallow() to avoid deeply nested URL for child operations where parent ID is redundant.


6. Request Validation (Context Recap)

Move validation into Form Requests for reusability.

php
class StorePostRequest extends FormRequest {
    public function rules(): array {
        return [
            'title' => ['required','string','max:150'],
            'body' => ['required','string'],
            'tags' => ['array'],
            'tags.*' => ['string','distinct'],
            'published_at' => ['nullable','date','after_or_equal:today'],
        ];
    }
}
class StorePostRequest extends FormRequest {
    public function rules(): array {
        return [
            'title' => ['required','string','max:150'],
            'body' => ['required','string'],
            'tags' => ['array'],
            'tags.*' => ['string','distinct'],
            'published_at' => ['nullable','date','after_or_equal:today'],
        ];
    }
}

Controller:

php
public function store(StorePostRequest $request) {
    $post = $request->user()->posts()->create($request->validated());
    return new PostResource($post);
}
public function store(StorePostRequest $request) {
    $post = $request->user()->posts()->create($request->validated());
    return new PostResource($post);
}

7. API Resources (Transformation Layer)

Use Laravel API Resources to decouple internal model from external shape.

php
// app/Http/Resources/PostResource.php
class PostResource extends JsonResource {
    public function toArray($request) {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'excerpt' => Str::limit($this->body, 180),
            'body' => $this->when($request->routeIs('posts.show'), $this->body),
            'author' => new UserSummaryResource($this->whenLoaded('user')),
            'tags' => TagResource::collection($this->whenLoaded('tags')),
            'published_at' => optional($this->published_at)->toIso8601String(),
            'created_at' => $this->created_at->toIso8601String(),
            'updated_at' => $this->updated_at->toIso8601String(),
            '_links' => [
                'self' => route('posts.show', $this),
            ],
        ];
    }
}
// app/Http/Resources/PostResource.php
class PostResource extends JsonResource {
    public function toArray($request) {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'excerpt' => Str::limit($this->body, 180),
            'body' => $this->when($request->routeIs('posts.show'), $this->body),
            'author' => new UserSummaryResource($this->whenLoaded('user')),
            'tags' => TagResource::collection($this->whenLoaded('tags')),
            'published_at' => optional($this->published_at)->toIso8601String(),
            'created_at' => $this->created_at->toIso8601String(),
            'updated_at' => $this->updated_at->toIso8601String(),
            '_links' => [
                'self' => route('posts.show', $this),
            ],
        ];
    }
}

Collections:

php
return PostResource::collection(Post::with(['user','tags'])->paginate(20));
return PostResource::collection(Post::with(['user','tags'])->paginate(20));

8. Standard Response Envelope

Decision: Use an envelope for consistency (optional, but helpful).

Success shape:

json
{
  "success": true,
  "data": { ... },
  "meta": { "request_id": "uuid", "version": "v1" }
}
{
  "success": true,
  "data": { ... },
  "meta": { "request_id": "uuid", "version": "v1" }
}

Error shape:

json
{
  "success": false,
  "error": { "code": "VALIDATION_ERROR", "message": "Invalid data", "details": { /* field errors */ } },
  "meta": { "request_id": "uuid" }
}
{
  "success": false,
  "error": { "code": "VALIDATION_ERROR", "message": "Invalid data", "details": { /* field errors */ } },
  "meta": { "request_id": "uuid" }
}

Inject request_id via middleware (e.g. UUID in X-Request-Id header) for traceability.


9. Error Handling & Exceptions

Map exceptions to structured responses.

php
// app/Exceptions/Handler.php
public function register(): void {
    $this->renderable(function (ModelNotFoundException $e, $request) {
        if ($request->expectsJson()) {
            return response()->json([
                'success' => false,
                'error' => [
                    'code' => 'NOT_FOUND',
                    'message' => 'Resource not found'
                ]
            ], 404);
        }
    });
}
// app/Exceptions/Handler.php
public function register(): void {
    $this->renderable(function (ModelNotFoundException $e, $request) {
        if ($request->expectsJson()) {
            return response()->json([
                'success' => false,
                'error' => [
                    'code' => 'NOT_FOUND',
                    'message' => 'Resource not found'
                ]
            ], 404);
        }
    });
}

Use custom exception classes for domain errors (InsufficientBalanceException). Return correct HTTP status codes (422 validation, 401 unauthenticated, 403 forbidden, 404 not found, 409 conflict, 429 too many requests, 500 server error).


10. Authentication & Authorization (API Focus)

Prefer token-based (Sanctum) for SPA/mobile.

Issue token:

php
$token = $user->createToken('mobile')->plainTextToken; // stored client-side
$token = $user->createToken('mobile')->plainTextToken; // stored client-side

Protect routes: Route::middleware('auth:sanctum')...

Authorization: reuse policies/gates. In controller:

php
$this->authorize('update', $post);
$this->authorize('update', $post);

Return 401 for no/invalid token, 403 for valid but forbidden.


11. Filtering, Sorting, Pagination

Examples:

  • Filtering: /posts?author_id=3&tag=laravel&published=true
  • Sorting: /posts?sort=-published_at,title (minus for DESC)
  • Pagination: Laravel paginator returns links, meta (keep them or transform).

Implementation snippet:

php
$query = Post::query();

if ($author = request('author_id')) $query->where('user_id', $author);
if ($tag = request('tag')) $query->whereHas('tags', fn($q)=>$q->where('name',$tag));
if (request('published')) $query->whereNotNull('published_at');

// Sorting
foreach (explode(',', request('sort','-published_at')) as $sort) {
    $direction = str_starts_with($sort,'-') ? 'desc':'asc';
    $column = ltrim($sort,'-');
    if (in_array($column,['published_at','title','created_at'])) {
        $query->orderBy($column,$direction);
    }
}

return PostResource::collection($query->paginate(20));
$query = Post::query();

if ($author = request('author_id')) $query->where('user_id', $author);
if ($tag = request('tag')) $query->whereHas('tags', fn($q)=>$q->where('name',$tag));
if (request('published')) $query->whereNotNull('published_at');

// Sorting
foreach (explode(',', request('sort','-published_at')) as $sort) {
    $direction = str_starts_with($sort,'-') ? 'desc':'asc';
    $column = ltrim($sort,'-');
    if (in_array($column,['published_at','title','created_at'])) {
        $query->orderBy($column,$direction);
    }
}

return PostResource::collection($query->paginate(20));

12. Idempotency & Safe Retries

Network retries can duplicate POST actions (e.g. payments). Provide an Idempotency-Key header.

Pattern:

  1. Client generates UUID per intent.
  2. Server stores hash/result by key (cache or table) within time window.
  3. Subsequent identical request returns stored response.

Quick example (pseudo):

php
$key = $request->header('Idempotency-Key');
$cacheKey = 'idem:'.$key;
if ($json = Cache::get($cacheKey)) return response()->json(json_decode($json,true));
$response = DB::transaction(function(){ /* create resource */ });
Cache::put($cacheKey, json_encode($response), 600);
return $response;
$key = $request->header('Idempotency-Key');
$cacheKey = 'idem:'.$key;
if ($json = Cache::get($cacheKey)) return response()->json(json_decode($json,true));
$response = DB::transaction(function(){ /* create resource */ });
Cache::put($cacheKey, json_encode($response), 600);
return $response;

13. Rate Limiting & Throttling

Use default api throttle or custom:

php
Route::middleware(['throttle:60,1'])->group(function() { /* ... */ });
Route::middleware(['throttle:60,1'])->group(function() { /* ... */ });

Or named limiter (in RouteServiceProvider):

php
RateLimiter::for('uploads', function($request){
    return Limit::perMinute(10)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('uploads', function($request){
    return Limit::perMinute(10)->by($request->user()?->id ?: $request->ip());
});

Expose limit status via response headers (Laravel auto: X-RateLimit-Limit, X-RateLimit-Remaining).


14. Caching & Performance

Layers:

  • HTTP: Cache-Control, ETag, Last-Modified.
  • Application: Cache::remember('posts.index', 60, fn()=> Post::popular()->take(10)->get()).
  • Database: Use indexes on filtering/sorting columns.
  • Avoid N+1: eager load relationships (with).

Conditional requests with ETag:

php
$etag = sha1($post->updated_at.$post->id);
if (request()->header('If-None-Match') === $etag) {
    return response()->noContent(304);
}
return (new PostResource($post))->additional(['meta'=>['etag'=>$etag]])
    ->withHeaders(['ETag'=>$etag]);
$etag = sha1($post->updated_at.$post->id);
if (request()->header('If-None-Match') === $etag) {
    return response()->noContent(304);
}
return (new PostResource($post))->additional(['meta'=>['etag'=>$etag]])
    ->withHeaders(['ETag'=>$etag]);

15. Concurrency & Transactions

For critical workflows combine DB transactions + optimistic or pessimistic locking.

Optimistic: version column increment check. Pessimistic: for update.

php
DB::transaction(function() use ($post){
    $fresh = Post::where('id',$post->id)->lockForUpdate()->first();
    $fresh->increment('views');
});
DB::transaction(function() use ($post){
    $fresh = Post::where('id',$post->id)->lockForUpdate()->first();
    $fresh->increment('views');
});

Return 409 CONFLICT on version mismatches.


16. Observability (Logging & Metrics)

  • Correlate logs with X-Request-Id.
  • Log structured JSON for APIs.
  • Use events for domain actions and attach listeners exporting metrics (Prometheus / StatsD).
  • Track latency percentiles, error rate, top slow queries.

Example structured log (Monolog config): channel per context (api, job, db).


17. Documentation & Discoverability

Use OpenAPI/Swagger.

  • Tools: darkaonline/l5-swagger, knuckleswtf/scribe.
  • Provide examples & error schemas.
  • Document rate limits, pagination, sorting keys, enums.

Include GET / root endpoint returning service metadata and versions.


18. Testing Strategy

Pyramid:

  • Feature tests for endpoints (happy path + error cases).
  • Unit tests for services / policies / resources logic.
  • Contract tests: freeze example responses (snapshot) sparingly.
  • Smoke test for health endpoint.

Example feature test:

php
public function test_can_create_post(): void {
    $user = User::factory()->create();
    $payload = [ 'title'=>'Hello','body'=>'World'];
    $this->actingAs($user, 'sanctum')
        ->postJson('/api/v1/posts', $payload)
        ->assertCreated()
        ->assertJsonPath('data.title', 'Hello');
}
public function test_can_create_post(): void {
    $user = User::factory()->create();
    $payload = [ 'title'=>'Hello','body'=>'World'];
    $this->actingAs($user, 'sanctum')
        ->postJson('/api/v1/posts', $payload)
        ->assertCreated()
        ->assertJsonPath('data.title', 'Hello');
}

19. Security & Hardening

  • Validate all input (already covered).
  • Enforce HTTPS (HSTS header at proxy).
  • Limit payload size (web server + Laravel post_max_size).
  • Avoid over-exposing data in resources (whitelist fields).
  • Use abort_unless($condition, 403) for quick guards.
  • Rotate tokens, allow revocation.
  • Protect against mass-assignment ($fillable).
  • Use Hash::make for secrets, never custom crypto.
  • Monitor for unusual token usage patterns.

20. Common Anti-Patterns

Anti-PatternWhy BadFix
Fat ControllersHard to testMove logic to services / actions
Exposing DB columns directlyLeaks internalsUse Resources to map fields
Returning 200 on errorsMisleads clientsUse proper 4xx/5xx codes
Mixed snake & camel caseInconsistentPick one (prefer snake or camel)
Large payloads without paginationPerformance issuesAlways paginate / chunk
Silent validation failuresHidden bugsReturn 422 with details
No versioning from startHard future changesAdd /v1 early

21. Putting It All Together (Mini Flow)

Flow: Client POST → Validate → Service (business logic) → Transaction → Model save → Resource transform → JSON envelope → Log + Metrics.


22. Summary

You can now design and implement well-structured Laravel APIs: model resources, enforce validation, transform outputs, secure endpoints, handle errors consistently, and prepare for scale & evolution.

Next meeting suggestion: Advanced API Topics – WebSockets & Real-Time Updates, API Rate Limit Strategies, or Queue & Async Processing.