Install
$ agentstack add skill-juststeveking-api-skill-api-skill ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
API Skill for Laravel Developers
This skill defines the exact patterns and rules for building scalable, reliable, and modern REST APIs in Laravel. All guidance here is prescriptive. When in doubt, follow the rule.
1. Route Organisation
Standalone APIs have no api prefix on any route. Routes live under routes/api/ as follows:
routes/
api/
routes.php ← entry point, requires all resource files
auth.php
posts.php ← one file per resource
users.php
routes/api/routes.php loads in each resource using a prefix and naming:
group(base_path(
path: 'routes/api/auth.php',
));
Route::as('posts:')->group(base_path(
path: 'routes/api/posts.php',
));
Each resource file owns its own version prefix. This keeps versioning explicit and debuggable per resource:
middleware(['auth:sanctum', 'throttle:api'])->group(function (): void {
Route::get('/', Posts\V1\IndexController::class)->name('v1:index');
Route::post('/', Posts\V1\StoreController::class)->name('v1:store');
Route::get('/{post}', Posts\V1\ShowController::class)->name('v1:show');
Route::put('/{post}', Posts\V1\UpdateController::class)->name('v1:update');
Route::delete('/{post}', Posts\V1\DestroyController::class)->name('v1:destroy');
});
- Always version from day one.
- Always use named routes, namespaced to their version (e.g.
posts:v1:indexandposts:v1:store). - The
throttle:apimiddleware must always be present.
2. Single-Action Invokable Controllers
Every controller is a final single-action invokable class. No resourceful controllers. No multiple methods per class. Just an invokable action and a constructor for any dependency injection.
Controllers live under app/Http/Controllers/{Resource}/{Version}/:
app/Http/Controllers/Posts/V1/
IndexController.php
ShowController.php
StoreController.php
UpdateController.php
DestroyController.php
Dependencies are always injected via the constructor. Never use Facades, app(), or resolve() inside a controller. The __invoke method handles the request and returns a response:
action->handle(
payload: $request->payload(),
);
return new JsonResponse(
data: new PostResource($post),
status: Response::HTTP_CREATED,
);
}
}
3. Form Requests and Payloads (DTOs)
Every state-mutating endpoint uses a Form Request. Form Requests live under app/Http/Requests/{Resource}/{Version}/.
The Form Request must expose a payload() method that returns a typed DTO from app/Http/Payloads/. This keeps controllers free of array handling and makes the data contract explicit:
['required', 'string', 'max:255'],
'content' => ['required', 'string'],
];
}
public function payload(): StorePayload
{
return new StorePayload(
title: $this->string('title')->toString(),
content: $this->string('content')->toString(),
userId: $this->user()->id,
);
}
}
Payloads (DTOs) are plain PHP objects. They have a typed constructor and a toArray() method that returns what Eloquent expects. They live in app/Http/Payloads/:
$this->title,
'content' => $this->content,
'user_id' => $this->userId,
];
}
}
4. API Resources
Always use Laravel's Eloquent API Resources to transform model data. Generate them with the --json-api CLI flag:
php artisan make:resource PostResource --json-api
Resources live under app/Http/Resources/. They define the contract for what consumers receive. Never return raw models or plain arrays from a controller.
Call JsonResource::withoutWrapping() in AppServiceProvider::boot() to disable the automatic data envelope globally. This ensures resources serialise consistently whether returned directly from a controller or encoded inside a JsonResponse:
use Illuminate\Http\Resources\Json\JsonResource;
public function boot(): void
{
JsonResource::withoutWrapping();
}
5. HTTP Status Codes
Always use Symfony's Response::HTTP_* constants. Never use bare integers. This makes intent readable at a glance:
| Scenario | Constant | |---|---| | Successful read | Response::HTTP_OK | | Resource created | Response::HTTP_CREATED | | Accepted for background processing | Response::HTTP_ACCEPTED | | Successful delete (no body) | Response::HTTP_NO_CONTENT | | Validation failed | Response::HTTP_UNPROCESSABLE_ENTITY | | Unauthenticated | Response::HTTP_UNAUTHORIZED | | Forbidden | Response::HTTP_FORBIDDEN | | Not found | Response::HTTP_NOT_FOUND | | Server error | Response::HTTP_INTERNAL_SERVER_ERROR |
Import: use Symfony\Component\HttpFoundation\Response;
6. Error Handling — RFC 9457 Problem Details
All error responses follow RFC 9457 Problem Details. The response shape is:
{
"type": "https://example.com/problems/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "The given data was invalid.",
"errors": {
"title": ["The title field is required."]
}
}
RFC 9457 also requires responses to be served with Content-Type: application/problem+json, not application/json. Enforce both the shape and the header through a single ProblemResponse class that implements Laravel's Responsable interface, living at app/Http/Responses/ProblemResponse.php. Every exception handler closure returns a ProblemResponse — no raw arrays, no ad-hoc JsonResponse construction:
return new ProblemResponse(
type: 'https://example.com/problems/not-found',
title: 'Not Found',
status: Response::HTTP_NOT_FOUND,
detail: 'The requested resource could not be found.',
);
The exception handler in bootstrap/app.php is responsible for rendering all exceptions. No exception should ever produce an HTML response on an API route. At minimum, the following must be handled explicitly:
| Exception | Status | Problem type slug | |---|---|---| | ValidationException | 422 | validation-error | | AuthenticationException | 401 | unauthenticated | | AuthorizationException | 403 | forbidden | | ModelNotFoundException | 404 | not-found | | Throwable (catch-all) | 500 | server-error |
The catch-all ensures nothing falls through to Laravel's default HTML error page. See [references/CONVENTIONS.md](references/CONVENTIONS.md) for the full ProblemResponse implementation and handler.
7. Authentication — Laravel Sanctum (Stateless Tokens)
All API routes are protected with Laravel Sanctum using stateless API tokens. Never use session-based authentication for API routes.
- Middleware:
auth:sanctum - Token abilities/scopes are used to restrict what a token can do
- Registration and login flows are synchronous — token issuance must complete in the same request
// Protecting routes
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function (): void {
// ...
});
// Checking abilities in a controller/action
$request->user()->tokenCan('posts:create');
8. Authorization — Policies
Authentication (section 7) confirms who the user is. Authorization confirms what they can do. Use Laravel Policies for resource-level access control.
Authorization belongs in the Form Request's authorize() method — not in the controller, not in the Action:
public function authorize(): bool
{
return $this->user()->can('update', $this->route('post'));
}
Policies live in app/Policies/. A failed authorization throws an AuthorizationException, which the exception handler (section 6) renders as a 403 Forbidden Problem Details response.
Actions operate on data that has already been validated and authorized. Never put policy checks inside an Action.
9. The Action Pattern
All business logic lives in Action classes under app/Actions/{Resource}/. Controllers orchestrate, Actions execute. One action per operation.
Actions are injected into controllers via the constructor. They receive a DTO and return a model or value:
database->transaction(
callback: fn (): Post => Post::query()->create(
attributes: $payload->toArray(),
),
);
}
}
Every action that writes to the database must be wrapped in $this->database->transaction() using the injected DatabaseManager.
10. Background Jobs — Non-Blocking Requests
Prefer non-blocking API responses. When an operation can be deferred, dispatch a job and return 202 Accepted:
simplePaginate(perPage: 15);
Never use paginate() on API endpoints.
13. Rate Limiting
Every API route group must have throttle:api middleware. No endpoint is ever unthrottled. Define rate limiters in App\Providers\AppServiceProvider (or a dedicated RateLimitServiceProvider):
RateLimiter::for('api', function (Request $request): Limit {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
14. Query Filtering — Spatie Laravel Query Builder
Use Spatie Laravel Query Builder for all filterable list endpoints. Never build raw query strings manually:
allowedFilters([
AllowedFilter::exact('status'),
AllowedFilter::partial('title'),
])
->allowedSorts(['created_at', 'title'])
->simplePaginate(perPage: 15);
return PostResource::collection($posts);
}
}
15. Testing
Test from the outside in. Drive tests through HTTP — assert on the response, not on internals. Cover both the happy path and the unhappy paths.
it('creates a post and returns 201', function (): void {
$user = User::factory()->create();
$this->actingAs($user)->postJson('/v1/posts', [
'title' => 'Hello World',
'content' => 'This is the content.',
])->assertStatus(
Response::HTTP_CREATED,
)->assertJsonPath('title', 'Hello World');
});
it('returns 422 when title is missing', function (): void {
$user = User::factory()->create();
$this->actingAs($user)->postJson('/v1/posts', [
'content' => 'Missing title.',
])->assertStatus(
Response::HTTP_UNPROCESSABLE_ENTITY,
)->assertJsonPath('status', 422)->assertJsonPath('title', 'Validation Error');
});
16. API Versioning — Sunset Middleware
When a versioned endpoint is scheduled for removal, signal this to consumers using the Sunset HTTP header (RFC 8594). This gives clients advance warning to migrate before the endpoint disappears.
Create a Sunset middleware at app/Http/Middleware/Sunset.php:
headers->set(
'Sunset',
(new DateTimeImmutable($date))->format(DateTimeInterface::RFC7231),
);
return $response;
}
}
Apply it per route group in the resource file when v1 has a known deprecation date:
Route::prefix('v1/posts')
->middleware(['auth:sanctum', 'throttle:api', 'sunset:2026-12-31'])
->group(function (): void {
// v1 routes — sunset date is communicated on every response
});
The v2 routes live in the same resource file without the middleware:
Route::prefix('v2/posts')
->middleware(['auth:sanctum', 'throttle:api'])
->group(function (): void {
// v2 routes
});
Register the middleware alias in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'sunset' => \App\Http\Middleware\Sunset::class,
]);
})
17. Forcing JSON Responses
Laravel's exception handler only returns JSON when $request->expectsJson() is true — which checks for the Accept: application/json header. If a client omits it, some exceptions (particularly those thrown before the handler fires, such as middleware failures) can still return HTML.
Fix this with a ForceJsonResponse middleware at app/Http/Middleware/ForceJsonResponse.php that sets the header on every inbound request:
headers->set('Accept', 'application/json');
return $next($request);
}
}
Register it as the first middleware on all API routes in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'sunset' => \App\Http\Middleware\Sunset::class,
'force.json' => \App\Http\Middleware\ForceJsonResponse::class,
]);
})
Apply it at the top of every resource route group:
Route::prefix('v1/posts')
->middleware(['force.json', 'auth:sanctum', 'throttle:api'])
->group(function (): void {
// ...
});
18. CORS
A standalone API will be called from origins other than its own domain. Laravel includes HandleCors middleware out of the box, backed by config/cors.php.
For a standalone API, set paths to ['*'] — there is no web prefix to protect:
// config/cors.php
return [
'paths' => ['*'],
'allowed_methods' => ['*'],
'allowed_origins' => ['*'], // lock this down per environment
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false,
];
In production, replace allowed_origins: ['*'] with an explicit list of trusted origins. Drive it from an environment variable so it can vary across environments without a code change:
'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', '*')),
HandleCors is applied globally by Laravel's default middleware stack, so no per-route changes are needed.
19. Code Quality Standards
Every PHP file in the project must meet these standards without exception:
declare(strict_types=1)on every file, always the first statement after the opening tagfinalon every class — controllers, actions, payloads, jobs, middleware, resources — unless explicitly designed for extensionmatchoverif/elseifchains and nested ternaries wherever a value is being selected- Named arguments on constructor calls and method calls with multiple parameters — this is already demonstrated throughout the examples and is required, not optional
- Full type coverage — typed properties, typed parameters, typed return types on every method
- PSR-12 naming and formatting conventions throughout
database->transaction(
callback: fn (): Post => Post::query()->create( // ✓ named argument
attributes: $payload->toArray(),
),
);
}
}
20. Model Identifiers — ULIDs
Never use auto-increment integer IDs on API-facing models. Integer IDs expose record counts, enable enumeration attacks, and create ordering assumptions consumers should not rely on.
Always use Laravel's HasUlids trait:
ulid('id')->primary();
21. Model Strictness
Call Model::shouldBeStrict() in AppServiceProvider::boot(). This enables three protections simultaneously:
- Prevents lazy loading — any unloaded relationship accessed outside of
with()throws immediately, surfacing N+1 queries during development - Prevents silently discarded attributes — assigning an attribute not in
$fillablethrows rather than silently ignoring it - Prevents accessing missing attributes — reading an attribute that doesn't exist on the model throws rather than returning
null
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::shouldBeStrict();
JsonResource::withoutWrapping();
RateLimiter::for('api', function (Requ
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [JustSteveKing](https://github.com/JustSteveKing)
- **Source:** [JustSteveKing/api-skill](https://github.com/JustSteveKing/api-skill)
- **License:** MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.