Laravel API Endpoints the “Production Way”: Form Requests, API Resources, and Policies (Hands-On)

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:

  • FormRequest for validation + input normalization
  • API Resources for consistent response shapes
  • Policies for 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 and authorize('view', $project).
  • Add searching: ?q=portal and filter by name.
  • 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

Your email address will not be published. Required fields are marked *