Laravel API Endpoints the “Production Way”: Form Requests, API Resources, and Policies (Hands-On)
If you’ve built a few Laravel APIs, you’ve probably shipped endpoints that work… but feel a bit messy: validation in controllers, inconsistent JSON, and authorization sprinkled everywhere. In this hands-on guide, you’ll build a clean, maintainable REST endpoint using three Laravel staples:
FormRequestfor validation + input normalizationAPI Resourcesfor consistent response shapesPoliciesfor authorization that scales
We’ll implement a small “Projects” API with create + list endpoints, including pagination and proper error responses.
What You’ll Build
POST /api/projects— create a project (validated + authorized)GET /api/projects— list projects (policy-scoped + paginated)- Consistent JSON with an API Resource
- Clean controller: almost no logic
1) Create the Model + Migration
Generate a model, migration, and factory:
php artisan make:model Project -m -f
Edit the migration in database/migrations/xxxx_xx_xx_create_projects_table.php:
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('projects', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->string('name'); $table->text('description')->nullable(); // a simple status field for filtering later $table->string('status')->default('active'); $table->timestamps(); $table->index(['user_id', 'status']); }); } public function down(): void { Schema::dropIfExists('projects'); } };
Run migrations:
php artisan migrate
Update the model (app/Models/Project.php) to allow mass assignment:
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Project extends Model { protected $fillable = [ 'user_id', 'name', 'description', 'status', ]; public function user(): BelongsTo { return $this->belongsTo(User::class); } }
2) Add Authorization with a Policy
Policies keep permission checks consistent and testable. Generate one:
php artisan make:policy ProjectPolicy --model=Project
Edit app/Policies/ProjectPolicy.php:
<?php namespace App\Policies; use App\Models\Project; use App\Models\User; class ProjectPolicy { // who can see a project? public function view(User $user, Project $project): bool { return $project->user_id === $user->id; } // who can create a project? public function create(User $user): bool { // simple rule: any authenticated user can create return true; } // who can list projects? (we’ll scope queries by user anyway) public function viewAny(User $user): bool { return true; } }
Make sure policies are registered. In many Laravel versions, auto-discovery works, but if not, map it in AuthServiceProvider.
3) Validate and Normalize Input with a Form Request
Create a request object:
php artisan make:request StoreProjectRequest
Edit app/Http/Requests/StoreProjectRequest.php:
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class StoreProjectRequest extends FormRequest { public function authorize(): bool { // Use policy authorization return $this->user() !== null && $this->user()->can('create', \App\Models\Project::class); } public function rules(): array { return [ 'name' => ['required', 'string', 'min:3', 'max:120'], 'description' => ['nullable', 'string', 'max:2000'], 'status' => ['nullable', 'string', Rule::in(['active', 'archived'])], ]; } protected function prepareForValidation(): void { // Normalize common input issues if ($this->has('name')) { $this->merge([ 'name' => trim((string) $this->input('name')), ]); } // Default status if not provided if (!$this->has('status')) { $this->merge(['status' => 'active']); } } }
Why this matters: the controller never needs to call Validator::make(), and normalization happens in one predictable place.
4) Return Consistent JSON with an API Resource
Create a resource:
php artisan make:resource ProjectResource
Edit app/Http/Resources/ProjectResource.php:
<?php namespace App\Http\Resources; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; class ProjectResource extends JsonResource { public function toArray(Request $request): array { return [ 'id' => $this->id, 'name' => $this->name, 'description' => $this->description, 'status' => $this->status, 'ownerId' => $this->user_id, 'createdAt' => $this->created_at?->toIso8601String(), 'updatedAt' => $this->updated_at?->toIso8601String(), ]; } }
Now every place you return a project uses the same shape—no more hand-built arrays scattered through controllers.
5) Keep Controllers Thin with a Small Service
This step is optional, but it’s a practical habit: move “create” logic to a service so controllers don’t grow over time.
Create app/Services/ProjectService.php:
<?php namespace App\Services; use App\Models\Project; use App\Models\User; class ProjectService { public function createForUser(User $user, array $data): Project { return Project::create([ 'user_id' => $user->id, 'name' => $data['name'], 'description' => $data['description'] ?? null, 'status' => $data['status'] ?? 'active', ]); } }
6) Implement the Controller
Create a controller:
php artisan make:controller Api/ProjectController
Edit app/Http/Controllers/Api/ProjectController.php:
<?php namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Requests\StoreProjectRequest; use App\Http\Resources\ProjectResource; use App\Models\Project; use App\Services\ProjectService; use Illuminate\Http\Request; class ProjectController extends Controller { public function index(Request $request) { $this->authorize('viewAny', Project::class); $status = $request->query('status'); // optional filter: active/archived $perPage = min((int) $request->query('perPage', 10), 50); $query = Project::query()->where('user_id', $request->user()->id); if ($status) { $query->where('status', $status); } $projects = $query ->orderByDesc('id') ->paginate($perPage); return ProjectResource::collection($projects); } public function store(StoreProjectRequest $request, ProjectService $service) { // At this point: // - authorize() already ran // - rules() already validated $project = $service->createForUser($request->user(), $request->validated()); return (new ProjectResource($project)) ->response() ->setStatusCode(201); } }
Notice what’s missing: no manual validation, no JSON handcrafting, no inline permission logic.
7) Add Routes
In routes/api.php:
<?php use App\Http\Controllers\Api\ProjectController; use Illuminate\Support\Facades\Route; Route::middleware('auth:sanctum')->group(function () { Route::get('/projects', [ProjectController::class, 'index']); Route::post('/projects', [ProjectController::class, 'store']); });
If you don’t use Sanctum yet, install it and protect your API properly. The key point: these endpoints assume an authenticated user via $request->user().
8) Try It with cURL
Create a project (replace $TOKEN):
curl -X POST "http://localhost:8000/api/projects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Client Portal", "description": "Internal portal for customer support.", "status": "active" }'
List projects (with pagination + optional filters):
curl "http://localhost:8000/api/projects?perPage=10&status=active" \ -H "Authorization: Bearer $TOKEN"
Because we used ProjectResource::collection($projects), Laravel will include pagination metadata in the response when the resource wraps a paginator.
9) Common Pitfalls (and How This Setup Avoids Them)
-
“My controller got huge.” Move logic to a service (as shown). Controllers should orchestrate, not compute.
-
“My JSON output is inconsistent.” Resources force a single response shape across endpoints.
-
“Authorization is scattered.” Policies centralize access rules so you don’t copy/paste checks.
-
“Validation rules are duplicated.” Form Requests are reusable (e.g., for update requests later).
Next Steps
If you want to extend this pattern:
- Add
UpdateProjectRequest+PATCH /api/projects/{project}using route model binding andauthorize('view', $project). - Add searching:
?q=portaland filter byname. - Add soft deletes (
SoftDeletes) and decide whether “archived” is a status or a deletion state. - Standardize errors further with an exception handler for domain-specific failures.
This structure is simple enough for junior developers to follow, but strong enough to keep your API codebase clean as it grows.
Leave a Reply