Appearance
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.
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
| Method | Typical Use | Safe | Idempotent | Partial Update |
|---|---|---|---|---|
| GET | Retrieve | Yes | Yes | N/A |
| POST | Create / Action | No | No | Yes (custom) |
| PUT | Replace | No | Yes | No |
| PATCH | Partial Update | No | Not guaranteed* | Yes |
| DELETE | Remove | No | Yes | N/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
DeprecationandSunsetheaders.
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-sideProtect 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:
- Client generates UUID per intent.
- Server stores hash/result by key (cache or table) within time window.
- 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::makefor secrets, never custom crypto. - Monitor for unusual token usage patterns.
20. Common Anti-Patterns
| Anti-Pattern | Why Bad | Fix |
|---|---|---|
| Fat Controllers | Hard to test | Move logic to services / actions |
| Exposing DB columns directly | Leaks internals | Use Resources to map fields |
| Returning 200 on errors | Misleads clients | Use proper 4xx/5xx codes |
| Mixed snake & camel case | Inconsistent | Pick one (prefer snake or camel) |
| Large payloads without pagination | Performance issues | Always paginate / chunk |
| Silent validation failures | Hidden bugs | Return 422 with details |
| No versioning from start | Hard future changes | Add /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.