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:
v1versioned 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:
sometimesmakes 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
FormRequestwithsometimesfor 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