Laravel PHP in Practice: Build a Clean JSON API with Form Requests, API Resources, and Policies

Laravel PHP in Practice: Build a Clean JSON API with Form Requests, API Resources, and Policies

Laravel makes it easy to ship an API fast—but “easy” can turn into messy controllers, inconsistent JSON, and security gaps. In this hands-on guide, you’ll build a small but production-shaped JSON API using three Laravel features that keep things clean:

  • FormRequest classes for validation + authorization
  • JsonResource for consistent responses
  • Policy classes for access control

The example: a “Projects” API where authenticated users can create projects and only edit/delete their own.

1) Create the Project model, migration, and factory

Generate the model + migration:

php artisan make:model Project -m 

Edit the migration to include an owner relationship and a couple of fields:

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

Define model fillable fields (or use guarded). Keep it explicit:

<?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); } } 

2) Add API routes (with auth)

Laravel offers multiple auth options. For a practical API, Laravel Sanctum is common. Assuming you already have auth set up, protect the routes with auth:sanctum:

<?php // routes/api.php use App\Http\Controllers\Api\ProjectController; use Illuminate\Support\Facades\Route; Route::middleware('auth:sanctum')->group(function () { Route::apiResource('projects', ProjectController::class); }); 

apiResource creates index/show/store/update/destroy routes automatically.

3) Build a controller that stays thin

Create the controller:

php artisan make:controller Api/ProjectController --api 

We’ll keep it thin by pushing validation to FormRequest and serialization to Resource.

4) Validate with Form Requests (and keep rules reusable)

Create request classes:

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

StoreProjectRequest handles creation rules and can also enforce authorization:

<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class StoreProjectRequest extends FormRequest { public function authorize(): bool { // Any authenticated user can create a project: return $this->user() !== null; } public function rules(): array { return [ 'name' => ['required', 'string', 'min:3', 'max:120'], 'description' => ['nullable', 'string', 'max:5000'], 'status' => ['nullable', 'in:active,archived'], ]; } } 

UpdateProjectRequest often allows partial updates. Use sometimes rules:

<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class UpdateProjectRequest extends FormRequest { public function authorize(): bool { // Authorization is handled by Policy via controller's authorizeResource(), // so this can remain true. If you prefer, you can also do per-request checks. return $this->user() !== null; } 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'], ]; } } 

Why this matters: validation stops cluttering controllers, and you get consistent error responses out of the box.

5) Serialize responses with API Resources

Create a resource:

php artisan make:resource ProjectResource 

Define the JSON shape. This is where you enforce consistency across endpoints:

<?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(), ]; } } 

Now every endpoint can return new ProjectResource($project) (or a collection) and you’ll never again hand-build arrays in controllers.

6) Secure access with Policies (only owners can mutate)

Create a policy:

php artisan make:policy ProjectPolicy --model=Project 

Edit it so only the owner can update/delete, while any authenticated user can view their own items:

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

Register policies in app/Providers/AuthServiceProvider.php if your Laravel version doesn’t auto-discover them:

<?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) Implement the API controller (thin + consistent)

Now wire it all together. Key ideas:

  • Use authorizeResource so policy checks run automatically per action
  • Scope queries to the authenticated user
  • Use ProjectResource for output
<?php namespace App\Http\Controllers\Api; 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) { $projects = Project::query() ->where('user_id', $request->user()->id) ->when($request->query('status'), fn ($q, $status) => $q->where('status', $status)) ->latest() ->paginate(10); return ProjectResource::collection($projects); } public function store(StoreProjectRequest $request) { $project = $request->user()->projects()->create($request->validated()); return (new ProjectResource($project)) ->response() ->setStatusCode(201); } public function show(Project $project) { return new ProjectResource($project); } public function update(UpdateProjectRequest $request, Project $project) { $project->update($request->validated()); return new ProjectResource($project); } public function destroy(Project $project) { $project->delete(); return response()->noContent(); } } 

One more thing: we used $request->user()->projects() in store(). Add this relationship to your User model:

<?php namespace App\Models; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; class User extends Authenticatable { public function projects(): HasMany { return $this->hasMany(Project::class); } } 

8) Try it with curl (end-to-end)

Assuming you have a valid Sanctum token in $TOKEN:

# Create curl -X POST "http://localhost:8000/api/projects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"Docs Revamp","description":"Rewrite onboarding docs","status":"active"}' # List (optionally filter by status) curl -X GET "http://localhost:8000/api/projects?status=active" \ -H "Authorization: Bearer $TOKEN" # Update curl -X PATCH "http://localhost:8000/api/projects/1" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"status":"archived"}' # Delete curl -X DELETE "http://localhost:8000/api/projects/1" \ -H "Authorization: Bearer $TOKEN" 

If you try to PATCH or DELETE a project that isn’t yours, Laravel will return 403 Forbidden automatically via the policy—no custom controller logic needed.

9) Practical tips to keep this pattern solid

  • Use pagination by default (paginate(10)) so list endpoints don’t accidentally melt your DB.

  • Keep JSON stable by always responding through JsonResource. If you change internal columns, your API doesn’t have to break.

  • Prefer Policy-driven auth over “if user_id !== auth_id” scattered across controllers.

  • Return correct status codes: 201 for create, 204 for delete.

  • Whitelist inputs: using $fillable plus $request->validated() avoids mass-assignment surprises.

Wrap-up

This approach scales well as your API grows: controllers stay readable, validation is centralized, responses are consistent, and authorization is enforced in one place. If you want a next step, add:

  • ProjectResource links (e.g., related endpoints)
  • search/sort query params with safe allow-lists
  • feature tests for policies and validation

But even without those, you now have a clean Laravel JSON API skeleton you can reuse in almost every project.


Leave a Reply

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