skills/laravel-api/SKILL.md
Build production-grade Laravel REST APIs using opinionated architecture patterns with Laravel best practices. Use when building, scaffoling, or reviewing Laravel APIs with specifications for stateless design, versioned endpoints, invokable controllers, Form Request DTOs, Action classes, JWT authentication, and PSR-12 code quality standards. Triggers on "build a Laravel API", "create Laravel endpoints", "add API authentication", "review Laravel API code", "refactor Laravel API", or "improve Laravel code quality".
npx skillsauth add noartem/skills laravel-apiInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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:
testing
Decompose large Vue 3 components into focused SFCs and composables with explicit contracts, simple templates, and SSR-safe side effects.
tools
shadcn-vue for Vue/Nuxt with Reka UI components and Tailwind. Use for accessible UI, Auto Form, data tables, charts, dark mode, MCP server setup, or encountering component imports, Reka UI errors.
documentation
Wrap multi-write operations in transactions; use dispatchAfterCommit and idempotency patterns to ensure consistency
tools
Stabilize workflows with Template Method or Strategy; extend by adding new classes instead of editing core logic