Laravel

API Development

Build robust REST APIs using Laravel Sanctum for token authentication, API Resources for consistent JSON responses, and versioned route prefixes.

Laravel Sanctum Install & Setup: Sanctum provides a simple token-based authentication system for SPAs and mobile apps. Install it via Composer, publish the config, and run migrations.
📄terminal
BASH
# 1. Install Sanctum
composer require laravel/sanctum

# 2. Publish config and migration
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

# 3. Run migrations (creates personal_access_tokens table)
php artisan migrate

# 4. Generate API controller
php artisan make:controller Api/V1/PostController --api --model=Post

# 5. Generate API Resource and Collection
php artisan make:resource PostResource
php artisan make:resource PostCollection
Sanctum Model & Middleware Setup: Add the HasApiTokens trait to the User model. Ensure Sanctum's middleware is registered for API routes.
📄app/Models/User.php
PHP
<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, Notifiable;

    protected $fillable = ['name', 'email', 'password'];

    protected $hidden = ['password', 'remember_token'];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password'          => 'hashed',
    ];
}

// bootstrap/app.php (Laravel 11) – ensure sanctum middleware is available
// return Application::configure(basePath: dirname(__DIR__))
//     ->withMiddleware(function (Middleware $middleware) {
//         $middleware->statefulApi();  // Sanctum stateful domains
//     })
//     ->create();

// config/sanctum.php – token expiration
// 'expiration' => 60 * 24 * 7,  // 7 days in minutes
// 'token_prefix' => '',
API Routes with Versioning: Organise routes in routes/api.php using version prefixes and auth:sanctum middleware to protect endpoints.
📄routes/api.php
PHP
<?php

use App\Http\Controllers\Api\V1\AuthController;
use App\Http\Controllers\Api\V1\PostController;
use App\Http\Controllers\Api\V1\UserController;
use Illuminate\Support\Facades\Route;

// ── Public routes (no auth required) ────────────────────────────────────────
Route::prefix('v1')->group(function () {

    // Authentication
    Route::post('/register', [AuthController::class, 'register']);
    Route::post('/login',    [AuthController::class, 'login']);

    // Public resource endpoints
    Route::apiResource('posts', PostController::class)->only(['index', 'show']);
});

// ── Protected routes (requires valid Sanctum token) ─────────────────────────
Route::prefix('v1')->middleware('auth:sanctum')->group(function () {

    // Auth actions
    Route::post('/logout',        [AuthController::class, 'logout']);
    Route::get('/user',           [UserController::class, 'me']);
    Route::put('/user',           [UserController::class, 'update']);
    Route::post('/user/avatar',   [UserController::class, 'updateAvatar']);

    // Authenticated resource (excludes public index/show – already registered)
    Route::apiResource('posts', PostController::class)->except(['index', 'show']);

    // Nested resource
    Route::apiResource('posts.comments', \App\Http\Controllers\Api\V1\CommentController::class)
        ->shallow();
});

// ── Version 2 routes ─────────────────────────────────────────────────────────
Route::prefix('v2')->middleware('auth:sanctum')->group(function () {
    Route::apiResource('posts', \App\Http\Controllers\Api\V2\PostController::class);
});
API Auth Controller: Handle token issuance on login, registration, and token revocation on logout. Always return structured JSON using helper methods.
📄app/Http/Controllers/Api/V1/AuthController.php
PHP
<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class AuthController extends Controller
{
    public function register(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'name'     => ['required', 'string', 'max:255'],
            'email'    => ['required', 'email', 'unique:users'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
        ]);

        $user  = User::create($validated);
        $token = $user->createToken('api-token')->plainTextToken;

        return response()->json([
            'message' => 'Registration successful.',
            'user'    => $user,
            'token'   => $token,
        ], 201);
    }

    public function login(Request $request): JsonResponse
    {
        $request->validate([
            'email'    => ['required', 'email'],
            'password' => ['required'],
        ]);

        $user = User::where('email', $request->email)->first();

        if (! $user || ! Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['The provided credentials are incorrect.'],
            ]);
        }

        // Optionally revoke old tokens
        $user->tokens()->delete();

        // Create token with abilities (scopes)
        $token = $user->createToken('api-token', ['posts:read', 'posts:write'])
                       ->plainTextToken;

        return response()->json([
            'message' => 'Login successful.',
            'user'    => $user,
            'token'   => $token,
        ]);
    }

    public function logout(Request $request): JsonResponse
    {
        // Revoke the current token
        $request->user()->currentAccessToken()->delete();

        return response()->json(['message' => 'Logged out successfully.']);
    }
}
API Resource Class: Transform Eloquent models into consistent JSON structures using JsonResource. Use ResourceCollection for paginated lists with metadata.
📄app/Http/Resources/PostResource.php
PHP
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'         => $this->id,
            'type'       => 'post',
            'attributes' => [
                'title'        => $this->title,
                'slug'         => $this->slug,
                'excerpt'      => $this->excerpt,
                'body'         => $this->when(
                    $request->routeIs('posts.show'),
                    $this->body   // only include full body on single-post endpoint
                ),
                'status'       => $this->status,
                'image_url'    => $this->image
                    ? asset('storage/' . $this->image)
                    : null,
                'published_at' => $this->published_at?->toIso8601String(),
                'created_at'   => $this->created_at->toIso8601String(),
                'updated_at'   => $this->updated_at->toIso8601String(),
            ],
            'relationships' => [
                'author' => new UserResource($this->whenLoaded('user')),
                'tags'   => TagResource::collection($this->whenLoaded('tags')),
            ],
            'links' => [
                'self' => route('posts.show', $this->id),
            ],
        ];
    }

    // Wrap collection with extra metadata
    public static function collection($resource)
    {
        return parent::collection($resource)->additional([
            'meta' => ['api_version' => 'v1'],
        ]);
    }
}
API Resource Controller: A clean API controller that returns proper HTTP status codes, uses API Resources for output, and handles not-found cases gracefully.
📄app/Http/Controllers/Api/V1/PostController.php
PHP
<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

class PostController extends Controller
{
    // GET /api/v1/posts
    public function index(Request $request): AnonymousResourceCollection
    {
        $posts = Post::query()
            ->with(['user', 'tags'])
            ->when($request->search, fn($q, $s) => $q->where('title', 'like', "%$s%"))
            ->when($request->status, fn($q, $s) => $q->where('status', $s))
            ->latest()
            ->paginate($request->per_page ?? 15);

        return PostResource::collection($posts);
    }

    // GET /api/v1/posts/{post}
    public function show(Post $post): PostResource
    {
        $post->loadMissing(['user', 'tags', 'comments.user']);
        return new PostResource($post);
    }

    // POST /api/v1/posts
    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'title'  => ['required', 'string', 'max:255'],
            'body'   => ['required', 'string'],
            'status' => ['required', 'in:draft,published'],
            'tags'   => ['nullable', 'array'],
            'tags.*' => ['exists:tags,id'],
        ]);

        $post = $request->user()->posts()->create($validated);
        $post->tags()->sync($validated['tags'] ?? []);

        return (new PostResource($post))
            ->response()
            ->setStatusCode(201);
    }

    // PUT /api/v1/posts/{post}
    public function update(Request $request, Post $post): PostResource
    {
        $this->authorize('update', $post); // Policy check

        $validated = $request->validate([
            'title'  => ['sometimes', 'string', 'max:255'],
            'body'   => ['sometimes', 'string'],
            'status' => ['sometimes', 'in:draft,published'],
        ]);

        $post->update($validated);

        return new PostResource($post->fresh());
    }

    // DELETE /api/v1/posts/{post}
    public function destroy(Post $post): JsonResponse
    {
        $this->authorize('delete', $post);
        $post->delete();

        return response()->json(['message' => 'Post deleted.'], 200);
    }
}
Returning JSON Responses & Error Handling: Use response()->json() for custom responses. Register a global exception handler in bootstrap/app.php to always return JSON for API requests.
📄bootstrap/app.php (Laravel 11) + json-response-helpers.php
PHP
// ── Common JSON response patterns ────────────────────────────────────────────

// Success with data
return response()->json([
    'success' => true,
    'data'    => $data,
    'message' => 'Operation completed.',
], 200);

// Created
return response()->json([
    'success' => true,
    'data'    => new PostResource($post),
], 201);

// No content
return response()->json(null, 204);

// Client error
return response()->json([
    'success' => false,
    'message' => 'Resource not found.',
    'errors'  => [],
], 404);

// ── Global API exception handler (bootstrap/app.php Laravel 11) ──────────────
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

return Application::configure(basePath: dirname(__DIR__))
    ->withExceptions(function (Exceptions $exceptions) {

        // Always return JSON for API routes
        $exceptions->shouldRenderJsonWhen(
            fn(Request $request) => $request->is('api/*')
        );

        // Custom 404 response
        $exceptions->render(function (NotFoundHttpException $e, Request $request) {
            if ($request->is('api/*')) {
                return response()->json([
                    'success' => false,
                    'message' => 'Resource not found.',
                ], 404);
            }
        });

        // Validation exception formatting
        $exceptions->render(function (ValidationException $e, Request $request) {
            if ($request->is('api/*')) {
                return response()->json([
                    'success' => false,
                    'message' => 'Validation failed.',
                    'errors'  => $e->errors(),
                ], 422);
            }
        });
    })
    ->create();
Token Abilities (Sanctum Scopes): Assign fine-grained abilities to tokens and check them in controllers or middleware to restrict what each token can do.
📄token-abilities-example.php
PHP
// Issue token with specific abilities (scopes)
$token = $user->createToken('mobile-app', [
    'posts:read',
    'posts:write',
    'profile:update',
])->plainTextToken;

// Check ability in a controller
public function store(Request $request): JsonResponse
{
    if (! $request->user()->tokenCan('posts:write')) {
        return response()->json([
            'message' => 'Token does not have permission to create posts.',
        ], 403);
    }
    // ... proceed
}

// Middleware shorthand in routes
Route::middleware(['auth:sanctum', 'abilities:posts:write'])->group(function () {
    Route::post('/posts', [PostController::class, 'store']);
});

// Check ANY of multiple abilities
Route::middleware(['auth:sanctum', 'ability:posts:read,posts:write'])->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
});

// Revoke specific tokens
$user->tokens()->where('name', 'mobile-app')->delete();

// Revoke all tokens (e.g. on password change)
$user->tokens()->delete();