
Laravel Api
- 32 installs
- 10 repo stars
- Updated June 13, 2026
- noartem/laravel-vue-skills
Helps with backend & apis tasks.
About
laravel-api is a Claude Code skill for backend & apis. It helps solo builders move faster with AI-assisted coding.
- laravel-api
- Backend & APIs
- AI-coding skill
Laravel Api by the numbers
- 32 all-time installs (skills.sh)
- Ranked #3,359 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/noartem/laravel-vue-skills --skill laravel-apiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 32 |
|---|---|
| repo stars | ★ 10 |
| Last updated | June 13, 2026 |
| Repository | noartem/laravel-vue-skills ↗ |
What it does
Helps with backend & apis tasks.
Files
Laravel 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:
1. Understand requirements - What resources? What operations? Authentication needed? 2. Initialize project structure - Set up routing, remove frontend bloat 3. Build first resource - Complete CRUD to establish pattern 4. Add authentication - JWT via PHP Open Source Saver 5. Iterate on remaining resources - Follow established pattern
Core Architecture Principles
Read references/architecture.md for comprehensive details. Key principles:
1. Stateless by design - No hidden dependencies, explicit data flow 2. Boundary-first - Clear separation of HTTP, business logic, data layers 3. Resource-scoped - Routes, controllers organized by resource 4. Version discipline - Namespace-based versioning, HTTP Sunset headers
Code Quality Standards
All code must follow Laravel best practices and PSR-12 standards:
1. Preserve Functionality - Refactorings change HOW code works, never WHAT it does 2. Explicit Over Implicit - Prefer clear, readable code over clever shortcuts 3. Type Declarations - Always use return types on methods, parameter types where beneficial 4. Avoid Nested Ternaries - Use match expressions, switch, or if/else for clarity 5. Consistent Naming - Follow PSR-12 and Laravel conventions strictly 6. 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 accessBuilding 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:
1. Create V2 namespace: App\Http\Controllers\Tasks\V2\ 2. Add V2 route group in resource file 3. 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:secretConfigure 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
1. Preserve Functionality - Ensure refactorings don't change behavior 2. Check Type Safety - Add missing return types and parameter types 3. Simplify Logic - Replace nested ternaries with match expressions 4. Extract Complexity - Move complex conditions into named methods 5. Verify Standards - Ensure PSR-12 compliance with declare(strict_types=1) 6. 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
<?php
declare(strict_types=1);
namespace App\Actions\{Resource};
use App\Http\Payloads\{Resource}\{Payload};
use App\Models\{Model};
final readonly class {Action}
{
public function handle({Payload} $payload): {Model}
{
// Implement action logic
}
}<?php
declare(strict_types=1);
namespace App\Http\Controllers\{Resource}\V1;
use App\Actions\{Resource}\{Action};
use App\Http\Requests\{Resource}\V1\{Request};
use App\Http\Responses\JsonDataResponse;
use Illuminate\Http\JsonResponse;
final readonly class {Controller}
{
public function __construct(
private {Action} ${action},
) {}
public function __invoke({Request} $request): JsonResponse
{
${result} = $this->{action}->handle(
payload: $request->payload(),
);
return new JsonDataResponse(
data: ${result},
status: 201,
);
}
}<?php
declare(strict_types=1);
namespace App\Http\Requests\{Resource}\V1;
use App\Http\Payloads\{Resource}\{Payload};
use Illuminate\Foundation\Http\FormRequest;
final class {Request} extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
// Add validation rules
];
}
public function payload(): {Payload}
{
return new {Payload}(
// Map request data to DTO properties
);
}
}<?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;
final class {Model} extends Model
{
use HasFactory;
use HasUlids;
protected $fillable = [
// Add fillable attributes
];
protected $casts = [
// Add casts
];
}<?php
declare(strict_types=1);
namespace App\Http\Payloads\{Resource};
final readonly class {Payload}
{
public function __construct(
// Define properties
) {}
public function toArray(): array
{
return [
// Map properties to array
];
}
}Steve's Laravel API Architecture
Core Principles
1. Stateless by Design
- No hidden dependencies in models or services
- Explicit data flow through DTOs
- Query building over implicit scopes
- Strict mode enabled to catch N+1 issues early
2. Boundary-First Approach
- Clear separation between HTTP, business logic, and data layers
- Form Requests handle validation and transform to DTOs
- DTOs carry data between layers
- Actions/Services contain business logic
- Models are data access only
3. Resource-Scoped Organization
- Route files scoped to resources (e.g.,
routes/api/tasks.php) - Controllers scoped to resources and versions (e.g.,
App/Http/Controllers/Tasks/V1) - All versions of a resource in one place for easy reference
4. Version Discipline
- Versioning through namespacing (V1, V2, etc.)
- HTTP Sunset headers for deprecation warnings
- Keep old versions working, don't break existing clients
5. Code Quality Standards (Laravel Best Practices)
- Preserve Functionality - Refactorings change HOW, never WHAT
- Explicit Over Implicit - Clear code beats clever code
- Type Safety - Use return types, parameter types, declare(strict_types=1)
- Avoid Nested Ternaries - Use match expressions for readability
- PSR-12 Compliance - Follow PHP-FIG standards strictly
- Proper Namespacing - Organize imports, use full type hints
Project Structure
app/
├── Actions/ # Single-purpose business logic
│ └── Tasks/
│ └── CreateTask.php
├── Services/ # Complex business logic (only when needed)
│ └── TaskService.php
├── Http/
│ ├── Controllers/ # Invokable, versioned, resource-scoped
│ │ └── Tasks/
│ │ ├── V1/
│ │ │ ├── StoreController.php
│ │ │ ├── IndexController.php
│ │ │ └── ShowController.php
│ │ └── V2/
│ │ └── StoreController.php
│ ├── Requests/ # Validation + transformation to DTOs
│ │ └── Tasks/
│ │ └── V1/
│ │ └── StoreTaskRequest.php
│ ├── Payloads/ # DTOs for data transfer
│ │ └── Tasks/
│ │ └── StoreTaskPayload.php
│ ├── Responses/ # Responsable classes
│ │ ├── JsonDataResponse.php
│ │ └── JsonErrorResponse.php
│ └── Middleware/
│ └── HttpSunset.php
├── Models/
│ └── Task.php
└── Providers/
└── AppServiceProvider.php # Model::shouldBeStrict()
routes/
├── api/
│ ├── routes.php # Main API routing file
│ └── tasks.php # All task routes, all versionsComponent Patterns
Models
- Always use ULIDs instead of auto-incrementing IDs
- Use
Model::shouldBeStrict()in AppServiceProvider to prevent N+1 issues - Keep models simple - data access only
- No business logic in models
Controllers
- Always invokable (single action per controller)
- Organized by resource and version:
Tasks/V1/StoreController.php - Minimal logic - coordinate between Form Request, Action/Service, and Response
- Type-hint Form Request in
__invokemethod
Form Requests
- Handle validation rules
- Include
payload()method that returns a DTO fromapp/Http/Payloads - Transform and sanitize input data
- Return strongly-typed DTOs for type safety
DTOs (Data Transfer Objects)
- Simple data classes in
app/Http/Payloads - Public properties for data
toArray()method for serialization- No business logic - pure data carriers
- Make data flow explicit and trackable
Actions
- Single-purpose classes in
app/Actions - One public method:
handle() - Contain focused business logic
- Return domain objects or DTOs
- Prefer Actions over Services
Services
- Only use when logic is too large/complex for an Action
- Coordinate multiple Actions or complex workflows
- Still maintain single responsibility
Response Classes
- Implement
Responsableinterface asResponse()returnsJsonResponse- Standard format:
{data, meta, errors} - Consistent API responses across the application
Routing
- Main entry:
routes/api/routes.php - Resource files:
routes/api/{resource}.php - Group by version within resource files
- Apply version-specific middleware
- HTTP Sunset middleware for deprecations
Error Handling
- Use
application/problem+jsonformat (RFC 7807) - Convert exceptions in application exception handler
- Consistent error structure across API
- Proper HTTP status codes
Query Building
- Use Spatie Query Builder for filtering, sorting, includes
- Start with
Model::query() - Create custom query builders only when needed
- Explicit eager loading with
allowedIncludes() - Avoid hidden query scopes
Authentication
- JWT tokens via PHP Open Source Saver package
- Stateless authentication
- Token in Authorization header:
Bearer {token} - Refresh token flow for long-lived sessions
Common Patterns
Creating a New Endpoint
1. Add route in routes/api/{resource}.php 2. Create invokable controller in App/Http/Controllers/{Resource}/V1/ 3. Create Form Request with validation + payload() method 4. Create DTO in App/Http/Payloads/{Resource}/ 5. Create Action in App/Actions/{Resource}/ 6. Create Response class (or use existing) 7. Wire it together in controller
Versioning an Endpoint
1. Create V2 namespace: App/Http/Controllers/{Resource}/V2/ 2. Copy and modify controller from V1 3. Update Form Request if validation changes 4. Update DTO if structure changes 5. Add V2 route group in routes/api/{resource}.php 6. Add Sunset header to V1 routes
Adding Query Capabilities
// In controller
use Spatie\QueryBuilder\QueryBuilder;
$tasks = QueryBuilder::for(Task::class)
->allowedFilters(['status', 'priority'])
->allowedSorts(['created_at', 'due_date'])
->allowedIncludes(['project', 'assignee'])
->paginate();Anti-Patterns to Avoid
- ❌ Auto-incrementing IDs (use ULIDs)
- ❌ Business logic in models
- ❌ Multiple actions per controller
- ❌ Direct request data access in controllers/actions
- ❌ Hidden query scopes
- ❌ Service classes when an Action would do
- ❌ Breaking changes without versioning
- ❌ Inconsistent response formats
- ❌ Missing N+1 query prevention
- ❌ Nested ternary operators
- ❌ Missing type declarations
- ❌ Overly compact code that sacrifices readability
Code Simplification Patterns
Match Expressions Over Nested Ternaries
// ❌ Avoid: Hard to read
$priority = $task->urgent ? 'high' : ($task->important ? 'medium' : 'low');
// ✅ Prefer: Clear and explicit
$priority = match (true) {
$task->urgent => 'high',
$task->important => 'medium',
default => 'low',
};Extract Complex Conditions
// ❌ Avoid: Inline complexity
if ($user->role === 'admin' || ($user->role === 'manager' && $user->department === 'sales')) {
// ...
}
// ✅ Prefer: Named method
if ($this->canAccessSalesData($user)) {
// ...
}
private function canAccessSalesData(User $user): bool
{
return $user->role === 'admin'
|| ($user->role === 'manager' && $user->department === 'sales');
}Explicit Type Declarations
// ❌ Avoid: Missing types
class UpdateTask
{
public function handle($task, $payload)
{
// ...
}
}
// ✅ Prefer: Full type safety
final readonly class UpdateTask
{
public function handle(Task $task, UpdateTaskPayload $payload): Task
{
// ...
}
}Declare Strict Types
Always start files with:
<?php
declare(strict_types=1);
namespace App\Actions\Tasks;Code Examples
This document contains complete, working examples of each component in Steve's Laravel API architecture.
Model with ULID
<?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',
'priority',
'due_date',
'project_id',
'assignee_id',
];
protected $casts = [
'due_date' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assignee_id');
}
}Form Request with DTO
<?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',
'string',
Rule::in(['pending', 'in_progress', 'completed']),
],
'priority' => [
'required',
'string',
Rule::in(['low', 'medium', 'high']),
],
'due_date' => ['nullable', 'date', 'after:today'],
'project_id' => ['required', 'string', 'exists:projects,id'],
'assignee_id' => ['nullable', 'string', 'exists:users,id'],
];
}
public function payload(): StoreTaskPayload
{
return new StoreTaskPayload(
title: $this->string('title')->toString(),
description: $this->string('description')->toString(),
status: $this->string('status')->toString(),
priority: $this->string('priority')->toString(),
dueDate: $this->date('due_date'),
projectId: $this->string('project_id')->toString(),
assigneeId: $this->string('assignee_id')->toString(),
);
}
}DTO (Data Transfer Object)
<?php
declare(strict_types=1);
namespace App\Http\Payloads\Tasks;
use DateTimeInterface;
final readonly class StoreTaskPayload
{
public function __construct(
public string $title,
public ?string $description,
public string $status,
public string $priority,
public ?DateTimeInterface $dueDate,
public string $projectId,
public ?string $assigneeId,
) {}
public function toArray(): array
{
return [
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'due_date' => $this->dueDate?->format('Y-m-d'),
'project_id' => $this->projectId,
'assignee_id' => $this->assigneeId,
];
}
}Action Class
<?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());
}
}Invokable Controller
<?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 Classes
Success Response
<?php
namespace App\Http\Responses;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\JsonResponse;
final readonly class JsonDataResponse implements Responsable
{
public function __construct(
private mixed $data,
private ?array $meta = null,
private int $status = 200,
) {
}
public function toResponse($request): JsonResponse
{
$response = ['data' => $this->data];
if ($this->meta !== null) {
$response['meta'] = $this->meta;
}
return new JsonResponse(
data: $response,
status: $this->status,
);
}
}Error Response
<?php
namespace App\Http\Responses;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\JsonResponse;
final readonly class JsonErrorResponse implements Responsable
{
public function __construct(
private array $errors,
private int $status = 400,
private ?array $meta = null,
) {
}
public function toResponse($request): JsonResponse
{
$response = ['errors' => $this->errors];
if ($this->meta !== null) {
$response['meta'] = $this->meta;
}
return new JsonResponse(
data: $response,
status: $this->status,
);
}
}Routes
Main API Routes File
<?php
// routes/api/routes.php
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () {
require __DIR__ . '/tasks.php';
require __DIR__ . '/projects.php';
});
Route::prefix('v2')->group(function () {
require __DIR__ . '/tasks.php';
});Resource Routes File
<?php
// routes/api/tasks.php
use App\Http\Controllers\Tasks\V1;
use Illuminate\Support\Facades\Route;
// V1 Routes
Route::middleware(['auth:api', 'http.sunset:2025-12-31'])->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);
});
// V2 Routes (when needed)
// Route::middleware(['auth:api'])->group(function () {
// Route::get('/tasks', \App\Http\Controllers\Tasks\V2\IndexController::class);
// Route::post('/tasks', \App\Http\Controllers\Tasks\V2\StoreController::class);
// });Index Controller with Query Builder
<?php
namespace App\Http\Controllers\Tasks\V1;
use App\Http\Responses\JsonDataResponse;
use App\Models\Task;
use Illuminate\Http\JsonResponse;
use Spatie\QueryBuilder\QueryBuilder;
final class IndexController
{
public function __invoke(): JsonResponse
{
$tasks = QueryBuilder::for(Task::class)
->allowedFilters([
'status',
'priority',
'project_id',
'assignee_id',
])
->allowedSorts([
'created_at',
'due_date',
'priority',
])
->allowedIncludes([
'project',
'assignee',
])
->paginate();
return new JsonDataResponse(
data: $tasks->items(),
meta: [
'current_page' => $tasks->currentPage(),
'per_page' => $tasks->perPage(),
'total' => $tasks->total(),
'last_page' => $tasks->lastPage(),
],
);
}
}Show Controller
<?php
namespace App\Http\Controllers\Tasks\V1;
use App\Http\Responses\JsonDataResponse;
use App\Models\Task;
use Illuminate\Http\JsonResponse;
use Spatie\QueryBuilder\QueryBuilder;
final class ShowController
{
public function __invoke(string $task): JsonResponse
{
$task = QueryBuilder::for(Task::where('id', $task))
->allowedIncludes([
'project',
'assignee',
])
->firstOrFail();
return new JsonDataResponse(
data: $task,
);
}
}Update Controller
<?php
namespace App\Http\Controllers\Tasks\V1;
use App\Actions\Tasks\UpdateTask;
use App\Http\Requests\Tasks\V1\UpdateTaskRequest;
use App\Http\Responses\JsonDataResponse;
use App\Models\Task;
use Illuminate\Http\JsonResponse;
final readonly class UpdateController
{
public function __construct(
private UpdateTask $updateTask,
) {
}
public function __invoke(UpdateTaskRequest $request, Task $task): JsonResponse
{
$updatedTask = $this->updateTask->handle(
task: $task,
payload: $request->payload(),
);
return new JsonDataResponse(
data: $updatedTask,
);
}
}Update Action
<?php
namespace App\Actions\Tasks;
use App\Http\Payloads\Tasks\UpdateTaskPayload;
use App\Models\Task;
final readonly class UpdateTask
{
public function handle(Task $task, UpdateTaskPayload $payload): Task
{
$task->update($payload->toArray());
return $task->fresh();
}
}Destroy Controller
<?php
namespace App\Http\Controllers\Tasks\V1;
use App\Models\Task;
use Illuminate\Http\JsonResponse;
final class DestroyController
{
public function __invoke(Task $task): JsonResponse
{
$task->delete();
return new JsonResponse(status: 204);
}
}HTTP Sunset Middleware
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class HttpSunset
{
public function handle(Request $request, Closure $next, string $date): Response
{
$response = $next($request);
$response->headers->set('Sunset', $date);
$response->headers->set(
'Deprecation',
'This API version is deprecated and will be removed on ' . $date
);
return $response;
}
}AppServiceProvider Setup
<?php
namespace App\Providers;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
// Prevent lazy loading and N+1 queries
Model::shouldBeStrict();
}
}Exception Handler (Problem+JSON)
<?php
namespace App\Exceptions;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;
class Handler extends ExceptionHandler
{
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
public function register(): void
{
$this->reportable(function (Throwable $e) {
//
});
}
public function render($request, Throwable $e): JsonResponse
{
if ($request->is('api/*')) {
return $this->renderApiException($e);
}
return parent::render($request, $e);
}
private function renderApiException(Throwable $e): JsonResponse
{
$status = $this->getStatusCode($e);
$title = $this->getTitle($e);
$detail = $e->getMessage();
$problem = [
'type' => 'about:blank',
'title' => $title,
'status' => $status,
'detail' => $detail,
];
if ($e instanceof ValidationException) {
$problem['errors'] = $e->errors();
}
return new JsonResponse(
data: $problem,
status: $status,
headers: ['Content-Type' => 'application/problem+json'],
);
}
private function getStatusCode(Throwable $e): int
{
if ($e instanceof HttpException) {
return $e->getStatusCode();
}
if ($e instanceof ModelNotFoundException) {
return 404;
}
if ($e instanceof AuthenticationException) {
return 401;
}
if ($e instanceof ValidationException) {
return 422;
}
return 500;
}
private function getTitle(Throwable $e): string
{
return match (true) {
$e instanceof ValidationException => 'Validation Failed',
$e instanceof ModelNotFoundException => 'Resource Not Found',
$e instanceof AuthenticationException => 'Authentication Required',
$e instanceof HttpException => $e->getMessage(),
default => 'Internal Server Error',
};
}
}Service Class Example (when needed)
<?php
namespace App\Services;
use App\Actions\Tasks\CreateTask;
use App\Actions\Tasks\AssignTask;
use App\Actions\Tasks\NotifyAssignee;
use App\Http\Payloads\Tasks\StoreTaskPayload;
use App\Models\Task;
final readonly class TaskService
{
public function __construct(
private CreateTask $createTask,
private AssignTask $assignTask,
private NotifyAssignee $notifyAssignee,
) {
}
/**
* Create a task and handle all related side effects
*/
public function createAndAssign(StoreTaskPayload $payload, string $assigneeId): Task
{
$task = $this->createTask->handle($payload);
$task = $this->assignTask->handle($task, $assigneeId);
$this->notifyAssignee->handle($task);
return $task;
}
}Laravel Code Quality & Refactoring
This guide covers code quality standards and refactoring patterns for Laravel APIs, inspired by Laravel's official code simplifier and PSR-12 standards.
Core Principles
1. Preserve Functionality
When refactoring, NEVER change what code does - only how it does it. All original features, outputs, and behaviors must remain intact.
Example:
// Before refactoring
public function calculateTotal($items) {
$total = 0;
foreach ($items as $item) {
$total += $item['price'] * $item['quantity'];
}
return $total;
}
// After refactoring - behavior unchanged
public function calculateTotal(array $items): float
{
return array_reduce(
array: $items,
callback: fn($total, $item) => $total + ($item['price'] * $item['quantity']),
initial: 0.0,
);
}2. Explicit Over Implicit
Prefer clear, readable code over clever shortcuts. Code is read far more often than it's written.
Example:
// ❌ Avoid: Too compact
$result = $a ?? $b ?: $c;
// ✅ Prefer: Clear intent
$result = $a ?? ($b ?: $c);
// Or better yet
$result = $a !== null ? $a : ($b !== false ? $b : $c);3. Type Declarations Always
Use return types on all methods and parameter types where beneficial. Enable strict types.
Example:
// ❌ Avoid: No types
class TaskService
{
public function findById($id)
{
return Task::find($id);
}
}
// ✅ Prefer: Full type safety
final readonly class TaskService
{
public function findById(string $id): ?Task
{
return Task::find($id);
}
}Refactoring Patterns
Match Expressions Over Nested Ternaries
Nested ternary operators are notoriously hard to read. Use match expressions for clarity.
Example 1: Status Determination
// ❌ 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',
};Example 2: Permission Levels
// ❌ Avoid: Complex ternary chain
$access = $user->is_admin
? 'full'
: ($user->is_manager
? 'limited'
: ($user->is_viewer ? 'read' : 'none'));
// ✅ Prefer: Match with role
$access = match ($user->role) {
'admin' => 'full',
'manager' => 'limited',
'viewer' => 'read',
default => 'none',
};Extract Complex Conditions
When conditional logic becomes complex, extract it into well-named methods.
Example:
// ❌ Avoid: Inline complexity
class TaskController
{
public function update(Task $task): Response
{
if (
auth()->user()->id === $task->owner_id
|| (auth()->user()->role === 'admin' && auth()->user()->department === $task->department)
|| (auth()->user()->role === 'manager' && auth()->user()->manages($task->project))
) {
$task->update($data);
}
}
}
// ✅ Prefer: Extracted methods
class TaskController
{
public function update(Task $task): Response
{
if ($this->canUpdateTask($task)) {
$task->update($data);
}
}
private function canUpdateTask(Task $task): bool
{
$user = auth()->user();
return $this->isTaskOwner($task, $user)
|| $this->isAuthorizedAdmin($task, $user)
|| $this->isProjectManager($task, $user);
}
private function isTaskOwner(Task $task, User $user): bool
{
return $user->id === $task->owner_id;
}
private function isAuthorizedAdmin(Task $task, User $user): bool
{
return $user->role === 'admin'
&& $user->department === $task->department;
}
private function isProjectManager(Task $task, User $user): bool
{
return $user->role === 'manager'
&& $user->manages($task->project);
}
}Named Constants Over Magic Values
Replace magic numbers and strings with named constants.
Example:
// ❌ Avoid: Magic values
if ($task->priority > 7) {
$this->escalate($task);
}
if ($user->status === 'A') {
$this->activate($user);
}
// ✅ Prefer: Named constants
class TaskPriority
{
public const LOW = 1;
public const MEDIUM = 5;
public const HIGH = 7;
public const CRITICAL = 10;
}
class UserStatus
{
public const ACTIVE = 'A';
public const INACTIVE = 'I';
public const SUSPENDED = 'S';
}
if ($task->priority > TaskPriority::HIGH) {
$this->escalate($task);
}
if ($user->status === UserStatus::ACTIVE) {
$this->activate($user);
}Simplify Collection Operations
Use Laravel's collection methods instead of manual loops when appropriate.
Example:
// ❌ Avoid: Manual loops
$activeUserIds = [];
foreach ($users as $user) {
if ($user->status === 'active') {
$activeUserIds[] = $user->id;
}
}
// ✅ Prefer: Collection methods
$activeUserIds = collect($users)
->filter(fn($user) => $user->status === 'active')
->pluck('id')
->toArray();PSR-12 Standards
File Structure
Every PHP file should follow this structure:
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Tasks\V1;
use App\Actions\Tasks\CreateTask;
use App\Http\Requests\Tasks\V1\StoreTaskRequest;
use Illuminate\Http\JsonResponse;
final readonly class StoreController
{
// Class contents
}Key elements: 1. Opening tag with no closing tag 2. declare(strict_types=1) immediately after opening tag 3. Namespace declaration 4. Use statements (alphabetically sorted) 5. One blank line before class declaration 6. Class declaration with visibility keywords
Naming Conventions
Classes:
- Use PascalCase
- Controllers:
{Resource}{Action}Controller(e.g.,StoreTaskController) - Actions:
{Action}{Resource}(e.g.,CreateTask) - DTOs:
{Action}{Resource}Payload(e.g.,StoreTaskPayload)
Methods:
- Use camelCase
- Be descriptive:
getUserById()notget() - Boolean methods: start with
is,has,can,should
Variables:
- Use camelCase
- Be descriptive:
$activeUsersnot$au - Avoid abbreviations unless universally known
Method Organization
Order methods by visibility and purpose:
final class TaskService
{
// 1. Constructor
public function __construct(
private TaskRepository $repository,
) {}
// 2. Public methods
public function createTask(StoreTaskPayload $payload): Task
{
return $this->repository->create($payload->toArray());
}
public function updateTask(Task $task, UpdateTaskPayload $payload): Task
{
return $this->repository->update($task, $payload->toArray());
}
// 3. Protected methods
protected function validateTask(Task $task): bool
{
return $task->status !== 'archived';
}
// 4. Private methods
private function logTaskCreation(Task $task): void
{
Log::info('Task created', ['task_id' => $task->id]);
}
}Code Review Checklist
When reviewing Laravel API code, check for:
Type Safety
- [ ] All methods have return type declarations
- [ ] Parameter types are declared where beneficial
- [ ] File starts with
declare(strict_types=1) - [ ] DTOs use readonly properties with types
Readability
- [ ] No nested ternary operators (use match instead)
- [ ] Complex conditions extracted to named methods
- [ ] Magic values replaced with named constants
- [ ] Variable and method names are descriptive
Laravel Conventions
- [ ] Models use HasUlids trait
- [ ] Controllers are invokable with single responsibility
- [ ] Form Requests have payload() method returning DTO
- [ ] Actions have single handle() method
- [ ] Response classes implement Responsable
Structure
- [ ] Proper namespace organization
- [ ] Imports alphabetically sorted
- [ ] One blank line between class sections
- [ ] PSR-12 formatting followed
Best Practices
- [ ] Model::shouldBeStrict() enabled in AppServiceProvider
- [ ] No business logic in models
- [ ] No direct request access in controllers/actions
- [ ] Explicit eager loading (no N+1 queries)
- [ ] API routes versioned and scoped by resource
Refactoring Workflow
1. Read and Understand - Fully understand what the code does before changing it 2. Write Tests - If tests don't exist, write them first to preserve behavior 3. Make One Change - Refactor one pattern at a time 4. Verify Tests Pass - Ensure functionality is preserved 5. Commit - Small, focused commits make review easier 6. Repeat - Continue until code meets quality standards
Common Anti-Patterns
Anti-Pattern: Business Logic in Models
// ❌ Avoid
class Task extends Model
{
public function complete(): void
{
$this->completed_at = now();
$this->save();
// Send notification
Mail::to($this->assignee)->send(new TaskCompleted($this));
// Update project status
$this->project->checkCompletion();
}
}
// ✅ Prefer: Logic in Actions
final readonly class CompleteTask
{
public function __construct(
private TaskNotificationService $notifications,
private ProjectStatusService $projectStatus,
) {}
public function handle(Task $task): Task
{
$task->update(['completed_at' => now()]);
$this->notifications->sendCompletionEmail($task);
$this->projectStatus->updateIfNeeded($task->project);
return $task->fresh();
}
}Anti-Pattern: God Controllers
// ❌ Avoid: Controller doing everything
class TaskController
{
public function store(Request $request)
{
$validated = $request->validate([...]);
$task = Task::create($validated);
Mail::to($task->assignee)->send(new TaskAssigned($task));
Cache::forget('tasks:' . $task->project_id);
return response()->json($task, 201);
}
}
// ✅ Prefer: Thin controller with dedicated classes
final readonly class StoreController
{
public function __construct(
private CreateTask $createTask,
) {}
public function __invoke(StoreTaskRequest $request): JsonResponse
{
$task = $this->createTask->handle($request->payload());
return new JsonDataResponse(data: $task, status: 201);
}
}Anti-Pattern: Inconsistent Response Formats
// ❌ Avoid: Different structures per endpoint
Route::get('/tasks', fn() => Task::all()); // Returns array
Route::get('/tasks/{task}', fn(Task $task) => ['data' => $task]); // Returns object
Route::post('/tasks', fn() => response()->json(['task' => $created])); // Different key
// ✅ Prefer: Consistent Responsable classes
Route::get('/tasks', fn() => new JsonDataResponse(Task::all()));
Route::get('/tasks/{task}', fn(Task $task) => new JsonDataResponse($task));
Route::post('/tasks', fn() => new JsonDataResponse($created, 201));Tools and Automation
Laravel Pint
Use Laravel Pint for automated code formatting:
composer require laravel/pint --dev
./vendor/bin/pintConfigure in pint.json:
{
"preset": "laravel",
"rules": {
"declare_strict_types": true,
"no_unused_imports": true,
"ordered_imports": true
}
}PHPStan
Use PHPStan for static analysis:
composer require --dev phpstan/phpstan
./vendor/bin/phpstan analyseConfigure in phpstan.neon:
parameters:
level: 8
paths:
- app
checkMissingIterableValueType: falseLarastan
Use Larastan for Laravel-specific analysis:
composer require --dev nunomaduro/larastan
./vendor/bin/phpstan analyse