Laravel in Practice: Build a Versioned REST API with Form Requests, API Resources, and Policies

Laravel in Practice: Build a Versioned REST API with Form Requests, API Resources, and Policies

Laravel makes it easy to ship APIs fast, but “easy” can turn into “messy” if validation, authorization, and responses are scattered across controllers. In this hands-on guide, you’ll build a clean, versioned REST API for a Project resource using:

  • FormRequest classes for validation + input normalization
  • API Resources for consistent JSON output
  • Policies for authorization rules
  • Basic API versioning (/api/v1) and pagination

Target audience: junior/mid devs who can run Laravel locally and want a practical “good structure” baseline.

1) Setup: Model, Migration, and Ownership

Create a model and migration:

php artisan make:model Project -m

Edit the migration to include an owner reference and a few fields:

// database/migrations/xxxx_xx_xx_create_projects_table.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(); $table->string('status')->default('active'); // active|archived $table->timestamps(); $table->index(['user_id', 'status']); }); } public function down(): void { Schema::dropIfExists('projects'); } };

Run migrations:

php artisan migrate

In the model, define fillable fields and relationship:

// app/Models/Project.php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class Project extends Model { protected $fillable = ['name', 'description', 'status']; public function user(): BelongsTo { return $this->belongsTo(User::class); } }

Why no user_id in $fillable? Because ownership is security-critical. We’ll set it server-side.

2) Versioned Routes + Controller Skeleton

Create a controller:

php artisan make:controller Api/V1/ProjectController --api

Define routes under /api/v1. In routes/api.php:

use App\Http\Controllers\Api\V1\ProjectController; use Illuminate\Support\Facades\Route; Route::prefix('v1') ->middleware('auth:sanctum') ->group(function () { Route::apiResource('projects', ProjectController::class); });

This assumes you’re using Laravel Sanctum. Any auth system works as long as you have an authenticated user via auth().

3) Validation with Form Requests (and a Tiny Normalization Trick)

Generate requests for create/update:

php artisan make:request StoreProjectRequest php artisan make:request UpdateProjectRequest

Store request:

// app/Http/Requests/StoreProjectRequest.php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class StoreProjectRequest extends FormRequest { public function authorize(): bool { // Policy handles authorization in controller via authorizeResource() return true; } protected function prepareForValidation(): void { // Normalize input once, early if ($this->has('name')) { $this->merge([ 'name' => trim($this->input('name')), ]); } } public function rules(): array { return [ 'name' => ['required', 'string', 'min:3', 'max:120'], 'description' => ['nullable', 'string', 'max:2000'], 'status' => ['nullable', 'in:active,archived'], ]; } }

Update request (often similar, but you can loosen requirements):

// app/Http/Requests/UpdateProjectRequest.php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class UpdateProjectRequest extends FormRequest { public function authorize(): bool { return true; } protected function prepareForValidation(): void { if ($this->has('name')) { $this->merge(['name' => trim($this->input('name'))]); } } public function rules(): array { return [ 'name' => ['sometimes', 'required', 'string', 'min:3', 'max:120'], 'description' => ['sometimes', 'nullable', 'string', 'max:2000'], 'status' => ['sometimes', 'in:active,archived'], ]; } }

sometimes prevents overwriting fields when clients send partial updates via PATCH.

4) Consistent Output with API Resources

Create a resource:

php artisan make:resource ProjectResource
// app/Http/Resources/ProjectResource.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, 'owner' => [ 'id' => $this->user_id, ], 'created_at' => $this->created_at?->toISOString(), 'updated_at' => $this->updated_at?->toISOString(), ]; } }

Resources help you keep responses stable when your database changes. You can also add links, computed fields, or include related data in a controlled way.

5) Authorization with Policies

Create a policy:

php artisan make:policy ProjectPolicy --model=Project
// app/Policies/ProjectPolicy.php namespace App\Policies; use App\Models\Project; use App\Models\User; class ProjectPolicy { public function viewAny(User $user): bool { return true; // any logged-in user can list their own projects } public function view(User $user, Project $project): bool { return $project->user_id === $user->id; } public function create(User $user): bool { return true; } public function update(User $user, Project $project): bool { return $project->user_id === $user->id; } public function delete(User $user, Project $project): bool { return $project->user_id === $user->id; } }

Make sure it’s registered. Newer Laravel versions can auto-discover policies; otherwise, map it in AuthServiceProvider.

6) Controller: Clean CRUD with Pagination and Safe Ownership

Now implement ProjectController. Note: we call authorizeResource once and let Laravel apply policy checks automatically to each action.

// app/Http/Controllers/Api/V1/ProjectController.php namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Http\Requests\StoreProjectRequest; use App\Http\Requests\UpdateProjectRequest; use App\Http\Resources\ProjectResource; use App\Models\Project; use Illuminate\Http\Request; class ProjectController extends Controller { public function __construct() { $this->authorizeResource(Project::class, 'project'); } public function index(Request $request) { $perPage = min((int) $request->query('per_page', 10), 50); $status = $request->query('status'); // optional filter $query = Project::query() ->where('user_id', $request->user()->id) ->latest(); if ($status) { $query->where('status', $status); } $projects = $query->paginate($perPage); return ProjectResource::collection($projects); } public function store(StoreProjectRequest $request) { $project = new Project($request->validated()); $project->user_id = $request->user()->id; // enforce ownership server-side $project->save(); return (new ProjectResource($project)) ->response() ->setStatusCode(201); } public function show(Project $project) { return new ProjectResource($project); } public function update(UpdateProjectRequest $request, Project $project) { $project->fill($request->validated()); $project->save(); return new ProjectResource($project); } public function destroy(Project $project) { $project->delete(); return response()->json(['message' => 'Deleted'], 200); } }

Notes that make this “practical”:

  • per_page is capped to prevent clients from requesting huge pages.
  • Filtering is explicit and safe (only status allowed here).
  • Ownership is enforced in both query scoping (index) and policy checks (show/update/delete).

7) Try It: Example Requests (cURL)

Assuming you have a Sanctum token in $TOKEN:

# Create curl -X POST "http://localhost:8000/api/v1/projects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Site Redesign", "description": "Landing page refresh + analytics", "status": "active" }' # List (with pagination + optional filter) curl "http://localhost:8000/api/v1/projects?per_page=5&status=active" \ -H "Authorization: Bearer $TOKEN" # Update (PATCH partial) curl -X PATCH "http://localhost:8000/api/v1/projects/1" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "status": "archived" }' # Delete curl -X DELETE "http://localhost:8000/api/v1/projects/1" \ -H "Authorization: Bearer $TOKEN"

8) Common Pitfalls (and How This Structure Avoids Them)

  • Putting validation in controllers: It starts small, then every endpoint grows a block of validation rules. Form Requests keep controllers focused on orchestration.

  • Leaking internal fields: If you return raw models, you might expose columns you add later (like internal flags). API Resources make responses intentional.

  • Authorization “oops” moments: Forgetting an if ($project->user_id !== ...) check is common. Policies + authorizeResource reduce the surface area for mistakes.

  • Mass-assignment security: Not including user_id in $fillable prevents clients from stealing ownership with a crafted payload.

9) Small Upgrades You Can Add Next

  • Sorting: allow ?sort=created_at and validate allowed columns to prevent SQL injection by column name.

  • Search: add a ?q=... query and apply it to name with where('name', 'like', "%$q%") (but validate length and consider full-text search later).

  • Soft deletes: use SoftDeletes so “delete” becomes reversible.

  • Rate limiting: add ->middleware('throttle:api') or a custom limit for write endpoints.

Wrap-Up

You now have a versioned Laravel API that’s easy to maintain: validation lives in Form Requests, authorization lives in Policies, and responses live in API Resources. This separation makes endpoints predictable, testable, and safer as your app grows—without adding a ton of boilerplate.

If you want to extend this into a real production pattern, the next best step is adding feature tests for each endpoint (especially policy rules and validation errors) and documenting your API with an OpenAPI spec.


Leave a Reply

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