Laravel API in Practice: Versioned Routes, Validation, Resources, and Authorization (Hands-On)

Laravel API in Practice: Versioned Routes, Validation, Resources, and Authorization (Hands-On)

Laravel is great for building APIs quickly, but “quick” can turn into “messy” if you skip structure. In this hands-on guide, you’ll build a small, production-friendly REST API feature with:

  • v1 versioned routes
  • clean input validation with FormRequest
  • consistent JSON output with API Resources
  • authorization with Policies
  • rate limiting per user/IP
  • a couple of feature tests so you can refactor safely

We’ll implement a simple “Projects” API (list/create/update/delete). This pattern scales to any domain.

1) Create the Project model + migration

Create a model and migration:

php artisan make:model Project -m

Edit the migration file:

<?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

Update the Project model:

<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Project extends Model { use HasFactory; protected $fillable = [ 'user_id', 'name', 'description', 'status', ]; public function user() { return $this->belongsTo(User::class); } }

2) Versioned API routes (v1)

Open routes/api.php and group your routes under /v1. We’ll also require auth via Sanctum (you can swap in Passport/JWT later).

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

Now define a custom rate limiter named api_v1 (next section).

3) Rate limiting: per user when logged in, otherwise per IP

In app/Providers/RouteServiceProvider.php (or AppServiceProvider in newer setups), configure a limiter:

<?php namespace App\Providers; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; class RouteServiceProvider extends ServiceProvider { public function boot(): void { RateLimiter::for('api_v1', function (Request $request) { $key = optional($request->user())->id ? 'user:' . $request->user()->id : 'ip:' . $request->ip(); return Limit::perMinute(60)->by($key); }); parent::boot(); } }

Now your API is protected against accidental loops and noisy clients.

4) Validation with Form Requests

Instead of validating inside controllers, create dedicated request classes:

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

app/Http/Requests/StoreProjectRequest.php:

<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class StoreProjectRequest extends FormRequest { public function authorize(): bool { return true; // policy will handle ownership later } public function rules(): array { return [ 'name' => ['required', 'string', 'min:3', 'max:120'], 'description' => ['nullable', 'string', 'max:5000'], 'status' => ['nullable', 'in:active,archived'], ]; } }

app/Http/Requests/UpdateProjectRequest.php:

<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class UpdateProjectRequest extends FormRequest { public function authorize(): bool { return true; } public function rules(): array { return [ 'name' => ['sometimes', 'required', 'string', 'min:3', 'max:120'], 'description' => ['sometimes', 'nullable', 'string', 'max:5000'], 'status' => ['sometimes', 'required', 'in:active,archived'], ]; } }

Two key points juniors often miss:

  • sometimes makes PATCH-style updates easy (only validate fields that are present).
  • Request classes keep controllers slim and make validation reusable.

5) Consistent JSON with API Resources

Create a resource:

php artisan make:resource ProjectResource

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, 'createdAt' => $this->created_at?->toISOString(), 'updatedAt' => $this->updated_at?->toISOString(), ]; } }

Why bother? Because it makes it safe to rename DB columns, add computed fields, or hide internal data without breaking clients.

6) Authorization with Policies (ownership checks)

Generate a policy:

php artisan make:policy ProjectPolicy --model=Project

app/Policies/ProjectPolicy.php:

<?php namespace App\Policies; use App\Models\Project; use App\Models\User; class ProjectPolicy { public function view(User $user, Project $project): bool { return $project->user_id === $user->id; } 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; } }

Register it (often auto-discovered in modern Laravel; if not, map it in AuthServiceProvider):

<?php namespace App\Providers; use App\Models\Project; use App\Policies\ProjectPolicy; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; class AuthServiceProvider extends ServiceProvider { protected $policies = [ Project::class => ProjectPolicy::class, ]; }

7) Controller: skinny, readable, testable

Create the controller under a versioned namespace:

php artisan make:controller Api/V1/ProjectController --api
<?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 index(Request $request) { $projects = Project::query() ->where('user_id', $request->user()->id) ->latest() ->paginate(10); return ProjectResource::collection($projects); } public function store(StoreProjectRequest $request) { $project = Project::create([ 'user_id' => $request->user()->id, 'name' => $request->validated('name'), 'description' => $request->validated('description'), 'status' => $request->validated('status', 'active'), ]); return (new ProjectResource($project)) ->response() ->setStatusCode(201); } public function show(Request $request, Project $project) { $this->authorize('view', $project); return new ProjectResource($project); } public function update(UpdateProjectRequest $request, Project $project) { $this->authorize('update', $project); $project->update($request->validated()); return new ProjectResource($project); } public function destroy(Request $request, Project $project) { $this->authorize('delete', $project); $project->delete(); return response()->json(['deleted' => true]); } }

Notice the flow:

  • Validation is handled before controller logic via FormRequest.
  • Authorization is explicit with $this->authorize().
  • Output shape is always controlled via ProjectResource.

8) Try it with curl

Assuming you have a Sanctum token in $TOKEN:

curl -X POST "http://localhost/api/v1/projects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"Website Redesign","description":"Rebuild marketing site","status":"active"}'
curl "http://localhost/api/v1/projects" \ -H "Authorization: Bearer $TOKEN"

Update with PATCH semantics (send only what you change):

curl -X PUT "http://localhost/api/v1/projects/1" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"status":"archived"}'

9) Add feature tests so changes don’t break behavior

Create tests:

php artisan make:test ProjectApiTest
<?php namespace Tests\Feature; use App\Models\Project; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class ProjectApiTest extends TestCase { use RefreshDatabase; public function test_user_can_create_and_list_projects(): void { $user = User::factory()->create(); $this->actingAs($user, 'sanctum') ->postJson('/api/v1/projects', [ 'name' => 'My Project', 'description' => 'Hello', ]) ->assertStatus(201) ->assertJsonPath('data.name', 'My Project'); $this->actingAs($user, 'sanctum') ->getJson('/api/v1/projects') ->assertOk() ->assertJsonCount(1, 'data'); } public function test_user_cannot_access_other_users_project(): void { $owner = User::factory()->create(); $attacker = User::factory()->create(); $project = Project::factory()->create(['user_id' => $owner->id]); $this->actingAs($attacker, 'sanctum') ->getJson("/api/v1/projects/{$project->id}") ->assertStatus(403); } }

If you don’t have factories yet, generate them:

php artisan make:factory ProjectFactory --model=Project
<?php namespace Database\Factories; use App\Models\Project; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; class ProjectFactory extends Factory { protected $model = Project::class; public function definition(): array { return [ 'user_id' => User::factory(), 'name' => $this->faker->sentence(3), 'description' => $this->faker->paragraph(), 'status' => 'active', ]; } }

Run tests:

php artisan test

10) Practical checklist to reuse on your next endpoint

  • Routes: group by version (/v1) and attach middleware (auth, throttle).
  • Validation: use FormRequest with sometimes for updates.
  • Authorization: enforce ownership in a Policy, call $this->authorize() in controller.
  • Response shape: always return Resource/ResourceCollection.
  • Tests: cover at least “happy path” + “cannot access others’ data”.

That’s the core of a clean Laravel API: predictable inputs, predictable outputs, and predictable permissions. Once this structure is in place, adding filters, sorting, and more endpoints becomes straightforward—and your future self will thank you.


Leave a Reply

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