| name | api-skill |
| description | Provides opinionated best practices and patterns for building production-ready REST APIs using Laravel. Use this skill when designing API endpoints, implementing resource controllers, or structuring JSON responses in a Laravel environment. |
| compatibility | Requires Laravel 13+ and PHP 8.5+. |
| license | MIT |
| metadata | {"author":"juststeveking","version":"2.0"} |
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:
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
Route::as('auth:')->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:
<?php
declare(strict_types=1);
use App\Http\Controllers\Posts;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/posts')->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:index and posts:v1:store).
- The
throttle:api middleware 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:
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Posts\V1;
use App\Actions\Posts\StorePostAction;
use App\Http\Requests\Posts\V1\StoreRequest;
use App\Http\Resources\PostResource;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
final class StoreController
{
public function __construct(
private readonly StorePostAction $action,
) {}
public function __invoke(StoreRequest $request): JsonResponse
{
$post = $this->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:
<?php
declare(strict_types=1);
namespace App\Http\Requests\Posts\V1;
use App\Http\Payloads\Posts\StorePayload;
use Illuminate\Foundation\Http\FormRequest;
final class StoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['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/:
<?php
declare(strict_types=1);
namespace App\Http\Payloads\Posts;
final class StorePayload
{
public function __construct(
public readonly string $title,
public readonly string $content,
public readonly string $userId,
) {}
public function toArray(): array
{
return [
'title' => $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 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
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function (): void {
});
$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:
<?php
declare(strict_types=1);
namespace App\Actions\Posts;
use App\Http\Payloads\Posts\StorePayload;
use App\Models\Post;
use Illuminate\Database\DatabaseManager;
final class StorePostAction
{
public function __construct(
private readonly DatabaseManager $database,
) {}
public function handle(StorePayload $payload): Post
{
return $this->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:
<?php
declare(strict_types=1);
namespace App\Actions\Posts;
use App\Http\Payloads\Posts\StorePayload;
use App\Jobs\Posts\StorePostJob;
final class StorePostAction
{
public function handle(StorePayload $payload): void
{
dispatch(new StorePostJob($payload));
}
}
return new JsonResponse(status: Response::HTTP_ACCEPTED);
When a job receives an Eloquent model, use the SerializesModels trait alongside Queueable. This stores the model's class and key on the queue rather than the full serialized object, and re-fetches it fresh when the job runs. Because the trait restores model properties via __wakeup(), model constructor properties must not be readonly:
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\SerializesModels;
final class DestroyPostJob implements ShouldQueue
{
use Queueable;
use SerializesModels;
public function __construct(
private Post $post, // not readonly — SerializesModels rehydrates via __wakeup()
) {}
}
Exceptions to non-blocking:
- User registration — the user needs a token immediately.
- Login / token issuance — must be synchronous.
- Any operation where the response body is needed by the client to proceed.
Use judgement. If the client cannot function without the result, make it synchronous.
11. Dependency Injection
Always inject dependencies via the constructor. Never use:
Facades (unless there is no alternative.)
app() helper
resolve() helper
If you know you need a class, declare it in the constructor. Laravel's container will resolve it automatically.
final class StoreController
{
public function __construct(
private readonly StorePostAction $action,
) {}
}
public function __invoke(): JsonResponse
{
$action = app(StorePostAction::class);
}
12. Pagination
Always use simplePaginate(). It avoids the expensive COUNT(*) query of paginate() and keeps the response lean:
Post::query()->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:
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Posts\V1;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\QueryBuilder;
final class IndexController
{
public function __invoke(): AnonymousResourceCollection
{
$posts = QueryBuilder::for(Post::class)
->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:
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use DateTimeImmutable;
use DateTimeInterface;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class Sunset
{
public function handle(Request $request, Closure $next, string $date): Response
{
$response = $next($request);
$response->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 {
});
The v2 routes live in the same resource file without the middleware:
Route::prefix('v2/posts')
->middleware(['auth:sanctum', 'throttle:api'])
->group(function (): void {
});
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:
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class ForceJsonResponse
{
public function handle(Request $request, Closure $next): Response
{
$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:
return [
'paths' => ['*'],
'allowed_methods' => ['*'],
'allowed_origins' => ['*'],
'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 tag
final on every class — controllers, actions, payloads, jobs, middleware, resources — unless explicitly designed for extension
match over if/elseif chains 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
<?php
declare(strict_types=1);
namespace App\Actions\Posts;
final class StorePostAction // ✓ final
{
public function handle(StorePayload $payload): Post // ✓ fully typed
{
return $this->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:
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Model;
final class Post extends Model
{
use HasUlids;
}
Laravel handles ULID generation automatically. No migration changes beyond defining the primary key column as string (26 characters) are required:
$table->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
$fillable throws 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 (Request $request): Limit {
return Limit::perMinute(60)->by(
key: $request->user()?->id ?: $request->ip(),
);
});
}
These are development-time errors by design. Fix the underlying issue — don't suppress the exception.
22. Anti-patterns
The following are explicitly prohibited. If you find yourself reaching for any of these, stop and apply the correct pattern instead.
| Anti-pattern | Why | What to do instead |
|---|
| Auto-increment integer primary keys | Exposes record counts, enables enumeration | Use HasUlids (section 20) |
| Business logic in models (scopes excluded) | Violates single responsibility, untestable in isolation | Use Actions (section 9) |
| Multi-method or resourceful controllers | Obscures intent, harder to locate logic | Single-action invokable controllers (section 2) |
| Returning raw models or arrays from controllers | Leaks schema, no transformation layer | Always use API Resources (section 4) |
app(), resolve(), or Facades in controllers/actions | Hides dependencies, harder to test | Constructor injection (section 11) |
DB::transaction() Facade | Same as above — hides the dependency | Inject DatabaseManager (section 9) |
paginate() on list endpoints | Runs an expensive COUNT(*) | Always simplePaginate() (section 12) |
| Unthrottled routes | Exposes all endpoints to brute force and abuse | Always throttle:api (section 13) |
| HTML error responses on API routes | Breaks every API client | ForceJsonResponse + full exception handler (sections 6, 17) |
Skipping declare(strict_types=1) | Silent type coercion bugs | Required on every file (section 19) |
| Authorization checks in Actions | Actions receive already-authorized data | Authorize in Form Request authorize() (section 8) |
References
For detailed folder structure and naming conventions, see references/CONVENTIONS.md.