Laravel API - Steve's Architecture
Build Laravel REST APIs with clean, stateless, resource-scoped architecture.
Quick Start
When user requests a Laravel API, follow this workflow:
Understand requirements - What resources? What operations? Authentication needed? Initialize project structure - Set up routing, remove frontend bloat Build first resource - Complete CRUD to establish pattern Add authentication - JWT via PHP Open Source Saver Iterate on remaining resources - Follow established pattern Core Architecture Principles
Read references/architecture.md for comprehensive details. Key principles:
Stateless by design - No hidden dependencies, explicit data flow Boundary-first - Clear separation of HTTP, business logic, data layers Resource-scoped - Routes, controllers organized by resource Version discipline - Namespace-based versioning, HTTP Sunset headers Code Quality Standards
All code must follow Laravel best practices and PSR-12 standards:
Preserve Functionality - Refactorings change HOW code works, never WHAT it does Explicit Over Implicit - Prefer clear, readable code over clever shortcuts Type Declarations - Always use return types on methods, parameter types where beneficial Avoid Nested Ternaries - Use match expressions, switch, or if/else for clarity Consistent Naming - Follow PSR-12 and Laravel conventions strictly Proper Namespacing - Organize imports logically, use full type hints
When reviewing or refactoring code:
Focus on clarity and maintainability over cleverness Simplify complex nested logic into readable structures Extract magic values into named constants or config Remove unnecessary complexity while preserving exact behavior Project Structure routes/api/ routes.php # Main entry point, version grouping tasks.php # All task routes, all versions projects.php # All project routes, all versions
app/Http/ Controllers/{Resource}/V1/ StoreController.php # Always invokable IndexController.php ShowController.php Requests/{Resource}/V1/ StoreTaskRequest.php # Validation + payload() method Payloads/{Resource}/ StoreTaskPayload.php # Simple DTOs with toArray() Responses/ JsonDataResponse.php # Implements Responsable JsonErrorResponse.php Middleware/ HttpSunset.php
app/Actions/{Resource}/ CreateTask.php # Single-purpose business logic
app/Services/ # Only when logic too complex for Actions
app/Models/ Task.php # HasUlids trait, simple data access
Building a New Resource Endpoint Step 1: Model
Always use ULIDs. Keep models simple - data access only.
'datetime', 'updated_at' => 'datetime', ]; public function project(): BelongsTo { return $this->belongsTo(Project::class); } } Step 2: Routes Create resource route file at routes/api/{resource}.php: use App\Http\Controllers\Tasks\V1; Route::middleware(['auth:api'])->group(function () { Route::get('/tasks', V1\IndexController::class); Route::post('/tasks', V1\StoreController::class); Route::get('/tasks/{task}', V1\ShowController::class); Route::patch('/tasks/{task}', V1\UpdateController::class); Route::delete('/tasks/{task}', V1\DestroyController::class); }); Include in routes/api/routes.php: Route::prefix('v1')->group(function () { require __DIR__ . '/tasks.php'; }); Step 3: DTO (Payload) Create at app/Http/Payloads/{Resource}/{Operation}Payload.php: $this->title, 'description' => $this->description, 'status' => $this->status, 'project_id' => $this->projectId, ]; } } Step 4: Form Request Create at app/Http/Requests/{Resource}/V1/{Operation}Request.php: ['required', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:1000'], 'status' => ['required', Rule::in(['pending', 'in_progress', 'completed'])], 'project_id' => ['required', 'string', 'exists:projects,id'], ]; } public function payload(): StoreTaskPayload { return new StoreTaskPayload( title: $this->string('title')->toString(), description: $this->string('description')->toString(), status: $this->string('status')->toString(), projectId: $this->string('project_id')->toString(), ); } } Step 5: Action Create at app/Actions/{Resource}/{Operation}.php: toArray()); } } Step 6: Controller Create invokable controller at app/Http/Controllers/{Resource}/V1/{Operation}Controller.php: createTask->handle( payload: $request->payload(), ); return new JsonDataResponse( data: $task, status: 201, ); } } Response Format Standard format for all responses: Success: { "data": {...}, "meta": {...} } Error (Problem+JSON): { "type": "about:blank", "title": "Validation Failed", "status": 422, "detail": "The given data was invalid", "errors": {...} } Query Building Use Spatie Query Builder for filtering, sorting, includes: use Spatie\QueryBuilder\QueryBuilder; $tasks = QueryBuilder::for(Task::class) ->allowedFilters(['status', 'priority']) ->allowedSorts(['created_at', 'due_date']) ->allowedIncludes(['project', 'assignee']) ->paginate(); Versioning Endpoints When creating V2: Create V2 namespace: App\Http\Controllers\Tasks\V2\ Add V2 route group in resource file Add Sunset middleware to V1 routes: Route::middleware(['auth:api', 'http.sunset:2025-12-31'])->group(function () { // V1 routes }); Authentication Setup Use PHP Open Source Saver JWT package: composer require php-open-source-saver/jwt-auth php artisan vendor:publish --provider="PHPOpenSourceSaver\JWTAuth\Providers\LaravelServiceProvider" php artisan jwt:secret Configure in config/auth.php: 'guards' => [ 'api' => [ 'driver' => 'jwt', 'provider' => 'users', ], ], Essential Setup Add to app/Providers/AppServiceProvider.php: use Illuminate\Database\Eloquent\Model; public function boot(): void { Model::shouldBeStrict(); // Prevent N+1 queries } Register HttpSunset middleware in app/Http/Kernel.php: protected $middlewareAliases = [ 'http.sunset' => \App\Http\Middleware\HttpSunset::class, ]; Anti-Patterns to Avoid Using auto-increment IDs instead of ULIDs Business logic in models Multiple actions per controller Accessing request data directly in controllers/actions Hidden query scopes Service classes when an Action would suffice Breaking changes without versioning Inconsistent response formats Nested ternary operators (use match expressions instead) Missing type declarations on methods and parameters Overly compact "clever" code that sacrifices readability Code Review & Refactoring When reviewing or refactoring Laravel API code, apply these principles: Simplification Checklist Preserve Functionality - Ensure refactorings don't change behavior Check Type Safety - Add missing return types and parameter types Simplify Logic - Replace nested ternaries with match expressions Extract Complexity - Move complex conditions into named methods Verify Standards - Ensure PSR-12 compliance with declare(strict_types=1) Improve Naming - Use descriptive names that reveal intent Match Expression Pattern Replace nested ternaries with match for clarity: // ❌ Avoid: Nested ternary $status = $task->completed_at ? ($task->verified ? 'verified' : 'completed') : ($task->started_at ? 'in_progress' : 'pending'); // ✅ Prefer: Match expression $status = match (true) { $task->completed_at && $task->verified => 'verified', $task->completed_at => 'completed', $task->started_at => 'in_progress', default => 'pending', }; References architecture.md - Comprehensive architectural patterns and principles code-examples.md - Complete working examples for every component code-quality.md - Laravel best practices, refactoring patterns, and PSR-12 standards Templates Template files in assets/templates/ for quick scaffolding: Controller.php FormRequest.php Payload.php Action.php Model.php ?>