This is Steve's opinionated take on building Laravel REST APIs with strict architectural boundaries. Everything is stateless, explicitly typed, and organized by resource. You get invokable controllers, form request DTOs with payload methods, single-purpose Action classes, and ULID-based models. Routes are versioned with HTTP Sunset headers baked in. The code quality section is aggressive about PSR-12, no nested ternaries, and preserving exact behavior during refactors. If you want a prescriptive pattern that eliminates decision fatigue on API structure, this locks you into a defensible convention. Good for teams that need consistency or solo devs tired of reinventing routing organization.
npx -y skills add juststeveking/laravel-api-skill --skill laravel-api --agent claude-codeInstalls into .claude/skills of the current project.
Build Laravel REST APIs with clean, stateless, resource-scoped architecture.
When user requests a Laravel API, follow this workflow:
Read references/architecture.md for comprehensive details. Key principles:
All code must follow Laravel best practices and PSR-12 standards:
When reviewing or refactoring code:
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
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);
}
}
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';
});
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,
];
}
}
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(),
);
}
}
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());
}
}
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,
);
}
}
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": {...}
}
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();
When creating V2:
App\Http\Controllers\Tasks\V2\Route::middleware(['auth:api', 'http.sunset:2025-12-31'])->group(function () {
// V1 routes
});
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',
],
],
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,
];
When reviewing or refactoring Laravel API code, apply these principles:
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',
};
Template files in assets/templates/ for quick scaffolding:
prisma/skills
firebase/agent-skills
Dexploarer/hyper-forge
itsmostafa/aws-agent-skills
prisma/skills