git clone https://github.com/ComeOnOliver/skillshub
T=$(mktemp -d) && git clone --depth=1 https://github.com/ComeOnOliver/skillshub "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/noartem/skills/laravel-api" ~/.claude/skills/comeonoliver-skillshub-laravel-api-2fa54d && rm -rf "$T"
skills/noartem/skills/laravel-api/SKILL.mdLaravel 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.
<?php declare(strict_types=1); namespace App\Models; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; final class Task extends Model { use HasFactory; use HasUlids; protected $fillable = [ 'title', 'description', 'status', 'project_id', ]; protected $casts = [ 'created_at' => '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:
<?php declare(strict_types=1); namespace App\Http\Payloads\Tasks; final readonly class StoreTaskPayload { public function __construct( public string $title, public ?string $description, public string $status, public string $projectId, ) {} public function toArray(): array { return [ 'title' => $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:
<?php declare(strict_types=1); namespace App\Http\Requests\Tasks\V1; use App\Http\Payloads\Tasks\StoreTaskPayload; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; final class StoreTaskRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'title' => ['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:
<?php declare(strict_types=1); namespace App\Actions\Tasks; use App\Http\Payloads\Tasks\StoreTaskPayload; use App\Models\Task; final readonly class CreateTask { public function handle(StoreTaskPayload $payload): Task { return Task::create($payload->toArray()); } }
Step 6: Controller
Create invokable controller at
app/Http/Controllers/{Resource}/V1/{Operation}Controller.php:
<?php declare(strict_types=1); namespace App\Http\Controllers\Tasks\V1; use App\Actions\Tasks\CreateTask; use App\Http\Requests\Tasks\V1\StoreTaskRequest; use App\Http\Responses\JsonDataResponse; use Illuminate\Http\JsonResponse; final readonly class StoreController { public function __construct( private CreateTask $createTask, ) {} public function __invoke(StoreTaskRequest $request): JsonResponse { $task = $this->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