RESTful API Development

Module 19: PHP Backend - Laravel

Introduction to API Development in Laravel

APIs (Application Programming Interfaces) provide a way for different software systems to communicate with each other. In today's connected world, APIs are the backbone of modern web and mobile applications, allowing frontend clients to interact with backend services, enabling third-party integrations, and providing data to mobile apps.

Laravel excels at API development with first-class support for building RESTful APIs. Think of APIs as digital communication protocols that standardize how different systems talk to each other, similar to how international protocols standardize communication between countries.

graph TD A[Client Applications] -->|HTTP Requests| B[Laravel API] B -->|JSON Responses| A B <-->|Interacts| C[Database] A --> D[Web Browsers] A --> E[Mobile Apps] A --> F[Third-party Services] A --> G[IoT Devices]

In this lecture, we'll focus on building RESTful APIs with Laravel - exploring the core principles, implementation approaches, and best practices for creating robust API endpoints that your clients and third-party services can consume.

Understanding REST Architecture

REST (Representational State Transfer) is an architectural style for designing networked applications. RESTful APIs use HTTP requests to perform CRUD (Create, Read, Update, Delete) operations on resources.

Key Principles of REST

HTTP Methods and CRUD Operations

HTTP Method CRUD Operation Description Example
GET Read Retrieve data GET /api/users
POST Create Create new resource POST /api/users
PUT Update Update entire resource PUT /api/users/5
PATCH Update Partial update PATCH /api/users/5
DELETE Delete Remove resource DELETE /api/users/5

RESTful URL Structure

A well-designed RESTful API uses URLs (endpoints) that focus on resources rather than actions:

graph LR subgraph "Resource Collections" A[GET /users] -->|List all users| B[Users Collection] C[POST /users] -->|Create new user| B end subgraph "Specific Resources" D[GET /users/5] -->|Get specific user| E[Single User] F[PUT /users/5] -->|Update user| E G[DELETE /users/5] -->|Delete user| E end subgraph "Nested Resources" H[GET /users/5/posts] -->|User's posts| I[User's Posts] J[POST /users/5/posts] -->|Create user's post| I end

This approach provides a consistent, intuitive way to interact with your API. Think of REST URLs as addresses to specific resources or collections, similar to physical addresses that help locate specific buildings or areas.

Setting Up APIs in Laravel

API Routes

Laravel provides a dedicated route file for API routes in routes/api.php:


// routes/api.php
use App\Http\Controllers\API\UserController;
use Illuminate\Support\Facades\Route;

// API routes are automatically prefixed with /api
Route::get('/users', [UserController::class, 'index']);
Route::post('/users', [UserController::class, 'store']);
Route::get('/users/{id}', [UserController::class, 'show']);
Route::put('/users/{id}', [UserController::class, 'update']);
Route::delete('/users/{id}', [UserController::class, 'destroy']);
            

All routes defined in the api.php file are automatically prefixed with /api and assigned to the api middleware group, which includes rate limiting by default.

Resource Controllers for APIs

For RESTful resource controllers, Laravel provides the apiResource method:


// Generate an API controller
php artisan make:controller API/ProductController --api

// routes/api.php
Route::apiResource('products', ProductController::class);

// Multiple API resources
Route::apiResources([
    'products' => ProductController::class,
    'categories' => CategoryController::class,
    'users' => UserController::class,
]);
            

The apiResource method excludes the create and edit methods that are typically used for rendering forms, since APIs don't usually need these:

Verb URI Action Route Name
GET /api/products index products.index
POST /api/products store products.store
GET /api/products/{product} show products.show
PUT/PATCH /api/products/{product} update products.update
DELETE /api/products/{product} destroy products.destroy

Route Model Binding

Laravel's route model binding is particularly useful for APIs:


// Implicit binding
Route::get('/users/{user}', [UserController::class, 'show']);

public function show(User $user)
{
    return response()->json($user);
}

// Custom key binding
public function show(User $user)
{
    // Uses 'username' column instead of 'id'
    return response()->json($user);
}

// In App\Models\User
public function getRouteKeyName()
{
    return 'username'; // Default is 'id'
}
            

Route model binding automatically resolves route parameters into model instances, which is elegant and saves you from writing repetitive model lookup code.

Creating API Controllers

API controllers handle the logic for processing API requests and returning appropriate responses. Let's create a complete example of a RESTful API controller:


// app/Http/Controllers/API/ProductController.php
namespace App\Http\Controllers\API;

use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\UpdateProductRequest;
use Illuminate\Http\Response;

class ProductController extends Controller
{
    /**
     * Display a listing of products.
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function index()
    {
        $products = Product::all();
        
        return response()->json([
            'status' => 'success',
            'data' => $products
        ]);
    }
    
    /**
     * Store a newly created product.
     *
     * @param  \App\Http\Requests\StoreProductRequest  $request
     * @return \Illuminate\Http\JsonResponse
     */
    public function store(StoreProductRequest $request)
    {
        $product = Product::create($request->validated());
        
        return response()->json([
            'status' => 'success',
            'message' => 'Product created successfully',
            'data' => $product
        ], Response::HTTP_CREATED); // 201
    }
    
    /**
     * Display the specified product.
     *
     * @param  \App\Models\Product  $product
     * @return \Illuminate\Http\JsonResponse
     */
    public function show(Product $product)
    {
        return response()->json([
            'status' => 'success',
            'data' => $product
        ]);
    }
    
    /**
     * Update the specified product.
     *
     * @param  \App\Http\Requests\UpdateProductRequest  $request
     * @param  \App\Models\Product  $product
     * @return \Illuminate\Http\JsonResponse
     */
    public function update(UpdateProductRequest $request, Product $product)
    {
        $product->update($request->validated());
        
        return response()->json([
            'status' => 'success',
            'message' => 'Product updated successfully',
            'data' => $product
        ]);
    }
    
    /**
     * Remove the specified product.
     *
     * @param  \App\Models\Product  $product
     * @return \Illuminate\Http\JsonResponse
     */
    public function destroy(Product $product)
    {
        $product->delete();
        
        return response()->json([
            'status' => 'success',
            'message' => 'Product deleted successfully',
        ]);
    }
}
            

This controller demonstrates several important aspects of API development:

Request Validation for APIs

Using form requests for API validation keeps your controllers clean:


// app/Http/Requests/StoreProductRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Response;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;

class StoreProductRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return $this->user()->can('create', Product::class);
    }

    /**
     * Get the validation rules that apply to the request.
     */
    public function rules(): array
    {
        return [
            'name' => 'required|string|max:255',
            'description' => 'required|string',
            'price' => 'required|numeric|min:0',
            'category_id' => 'required|exists:categories,id',
            'is_active' => 'boolean',
        ];
    }
    
    /**
     * Handle a failed validation attempt.
     */
    protected function failedValidation(Validator $validator)
    {
        throw new HttpResponseException(
            response()->json([
                'status' => 'error',
                'message' => 'Validation failed',
                'errors' => $validator->errors()
            ], Response::HTTP_UNPROCESSABLE_ENTITY) // 422
        );
    }
    
    /**
     * Handle a failed authorization attempt.
     */
    protected function failedAuthorization()
    {
        throw new HttpResponseException(
            response()->json([
                'status' => 'error',
                'message' => 'You do not have permission to create products'
            ], Response::HTTP_FORBIDDEN) // 403
        );
    }
}
            

By overriding the failedValidation and failedAuthorization methods, we ensure that API requests receive JSON responses rather than redirects when validation or authorization fails.

Returning API Responses

JSON Responses

Laravel makes it easy to return JSON responses:


// Basic JSON response
return response()->json($data);

// With custom status code
return response()->json($data, 201);

// With custom headers
return response()->json($data, 200, [
    'X-API-Version' => '1.0',
]);

// From a model or collection
return response()->json(User::all());
return response()->json(User::find(1));
            

Consistent Response Structure

It's a good practice to use a consistent structure for your API responses:


// Success response
return response()->json([
    'status' => 'success',
    'data' => $resource,
    'message' => 'Operation successful'
]);

// Error response
return response()->json([
    'status' => 'error',
    'message' => 'Something went wrong',
    'errors' => $validationErrors
], $statusCode);
            

This consistent structure makes it easier for API consumers to work with your responses.

HTTP Status Codes

Using appropriate HTTP status codes is crucial for RESTful APIs:

Code Description Use Case
200 OK Success GET, PUT, PATCH requests
201 Created Resource created POST requests that create resources
204 No Content Success with no response body DELETE requests
400 Bad Request Invalid request Malformed requests
401 Unauthorized Authentication required Missing authentication
403 Forbidden Authenticated but not authorized Insufficient permissions
404 Not Found Resource not found Resource doesn't exist
422 Unprocessable Entity Validation failed Invalid input data
500 Internal Server Error Server error Something went wrong server-side

Laravel defines these status codes as constants in the Illuminate\Http\Response class:


use Illuminate\Http\Response;

return response()->json($data, Response::HTTP_CREATED); // 201
return response()->json($error, Response::HTTP_NOT_FOUND); // 404
            

Customizing Exception Handling for APIs

You can customize API responses for exceptions in the App\Exceptions\Handler class:


// app/Exceptions/Handler.php
public function render($request, Throwable $exception)
{
    // For API requests, return JSON responses
    if ($request->expectsJson()) {
        if ($exception instanceof ModelNotFoundException) {
            return response()->json([
                'status' => 'error',
                'message' => 'Resource not found'
            ], Response::HTTP_NOT_FOUND);
        }
        
        if ($exception instanceof AuthenticationException) {
            return response()->json([
                'status' => 'error',
                'message' => 'Unauthenticated'
            ], Response::HTTP_UNAUTHORIZED);
        }
        
        if ($exception instanceof AuthorizationException) {
            return response()->json([
                'status' => 'error',
                'message' => 'This action is unauthorized'
            ], Response::HTTP_FORBIDDEN);
        }
        
        // Handle validation exceptions
        if ($exception instanceof ValidationException) {
            return response()->json([
                'status' => 'error',
                'message' => 'Validation failed',
                'errors' => $exception->errors()
            ], Response::HTTP_UNPROCESSABLE_ENTITY);
        }
        
        // Default error response
        return response()->json([
            'status' => 'error',
            'message' => $exception->getMessage(),
        ], $this->isHttpException($exception) ? $exception->getStatusCode() : 500);
    }
    
    return parent::render($request, $exception);
}
            

This ensures that all exceptions are converted to appropriate JSON responses for API clients.

Pagination and Filtering

API Pagination

Pagination is essential for APIs that return large collections. Laravel's pagination integrates seamlessly with APIs:


// Controller method with pagination
public function index()
{
    $products = Product::paginate(15);
    
    return response()->json($products);
}
            

This returns a JSON structure with pagination metadata:


{
    "current_page": 1,
    "data": [
        // Products here
    ],
    "first_page_url": "http://example.com/api/products?page=1",
    "from": 1,
    "last_page": 5,
    "last_page_url": "http://example.com/api/products?page=5",
    "links": [
        // Pagination links
    ],
    "next_page_url": "http://example.com/api/products?page=2",
    "path": "http://example.com/api/products",
    "per_page": 15,
    "prev_page_url": null,
    "to": 15,
    "total": 73
}
            

This structure allows API consumers to navigate through large datasets efficiently.

Filtering, Sorting, and Searching

APIs often need to support filtering, sorting, and searching. Here's how to implement these features:


public function index(Request $request)
{
    $query = Product::query();
    
    // Filter by category
    if ($request->has('category_id')) {
        $query->where('category_id', $request->category_id);
    }
    
    // Filter by price range
    if ($request->has('min_price')) {
        $query->where('price', '>=', $request->min_price);
    }
    
    if ($request->has('max_price')) {
        $query->where('price', '<=', $request->max_price);
    }
    
    // Filter by active status
    if ($request->has('is_active')) {
        $query->where('is_active', $request->boolean('is_active'));
    }
    
    // Search by name or description
    if ($request->has('search')) {
        $searchTerm = $request->search;
        $query->where(function ($q) use ($searchTerm) {
            $q->where('name', 'like', "%{$searchTerm}%")
              ->orWhere('description', 'like', "%{$searchTerm}%");
        });
    }
    
    // Sort by field
    $sortField = $request->input('sort_by', 'created_at');
    $sortDirection = $request->input('sort_dir', 'desc');
    
    // Whitelist allowed sort fields for security
    $allowedSortFields = ['name', 'price', 'created_at'];
    if (in_array($sortField, $allowedSortFields)) {
        $query->orderBy($sortField, $sortDirection === 'asc' ? 'asc' : 'desc');
    }
    
    // Paginate results
    $perPage = $request->input('per_page', 15);
    $products = $query->paginate($perPage);
    
    return response()->json($products);
}
            

This approach allows API consumers to construct complex queries:

graph TD A[API Request with Query Parameters] --> B[Parse Parameters] B --> C[Build Database Query] C --> D[Apply Filters] D --> E[Apply Sorting] E --> F[Apply Pagination] F --> G[Return JSON Response]

Think of this as building a customized query processor that translates user requests into database queries, similar to how a search engine processes user queries.

API Resources and Transformations

When returning models directly from APIs, you may expose internal fields or miss related data. Laravel's API Resources solve this by providing a transformation layer between your models and JSON responses.

Creating API Resources


// Generate a resource class
php artisan make:resource ProductResource

// Generate a resource collection
php artisan make:resource ProductCollection

// app/Http/Resources/ProductResource.php
namespace App\Http\Resources;

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

class ProductResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'slug' => $this->slug,
            'description' => $this->description,
            'price' => $this->price,
            'formatted_price' => '$' . number_format($this->price, 2),
            'discount_percentage' => $this->when($this->discount_percentage, function() {
                return $this->discount_percentage . '%';
            }),
            'category' => new CategoryResource($this->whenLoaded('category')),
            'tags' => TagResource::collection($this->whenLoaded('tags')),
            'is_in_stock' => $this->stock_quantity > 0,
            'created_at' => $this->created_at,
            'updated_at' => $this->updated_at,
        ];
    }
    
    /**
     * Get additional data that should be returned with the resource array.
     *
     * @return array
     */
    public function with(Request $request): array
    {
        return [
            'meta' => [
                'version' => '1.0',
            ],
        ];
    }
}
            

Using API Resources


// Single resource
public function show(Product $product)
{
    return new ProductResource($product);
}

// Collection of resources
public function index()
{
    $products = Product::with(['category', 'tags'])->paginate(15);
    return ProductResource::collection($products);
}

// Custom resource collection class
class ProductCollection extends ResourceCollection
{
    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'total_products' => $this->collection->count(),
                'custom_data' => 'value',
            ],
        ];
    }
}

// Using the custom collection
public function index()
{
    $products = Product::paginate(15);
    return new ProductCollection($products);
}
            

Conditional Attributes and Relationships


// Conditional attributes
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        // Only include if the condition is true
        'secret_field' => $this->when($request->user()->isAdmin(), $this->secret_field),
        // Include with a default value
        'status' => $this->when(isset($this->status), $this->status, 'pending'),
    ];
}

// Conditional relationships
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        // Only include if relationship is loaded
        'category' => new CategoryResource($this->whenLoaded('category')),
        // Conditional relationship based on user role
        'reviews' => $this->when(
            $request->user()->can('view', ReviewResource::class),
            ReviewResource::collection($this->whenLoaded('reviews'))
        ),
    ];
}
            

API Resources provide a clean, dedicated place for your response transformation logic. They're like specialized translators that convert your internal model structure into a public-facing API format.

Resource Response Customization


// Change the resource response format
app(\Illuminate\Http\Resources\Json\JsonResource::class)
    ->withoutWrapping();

// Or for a specific response
return (new ProductResource($product))
    ->response()
    ->header('X-Custom-Header', 'Value');
            

This level of control ensures that your API responses meet your exact specifications.

Versioning Your API

As your API evolves, versioning becomes important to maintain backward compatibility. Laravel supports several versioning strategies:

URL Versioning


// routes/api.php
// v1 routes
Route::prefix('v1')->group(function () {
    Route::apiResource('products', Api\V1\ProductController::class);
});

// v2 routes
Route::prefix('v2')->group(function () {
    Route::apiResource('products', Api\V2\ProductController::class);
});

// Example URLs:
// /api/v1/products
// /api/v2/products
            

Header-Based Versioning


// routes/api.php
Route::middleware('api.version:v1')->group(function () {
    Route::apiResource('products', Api\V1\ProductController::class);
});

Route::middleware('api.version:v2')->group(function () {
    Route::apiResource('products', Api\V2\ProductController::class);
});

// app/Http/Middleware/ApiVersion.php
public function handle($request, Closure $next, $version)
{
    if ($request->header('Accept-Version') !== $version) {
        return $next($request);
    }
    
    return $next($request);
}

// Register the middleware in Kernel.php
protected $routeMiddleware = [
    // ...
    'api.version' => \App\Http\Middleware\ApiVersion::class,
];
            

Subdomain Versioning


// routes/api.php
Route::domain('api-v1.' . env('APP_URL'))->group(function () {
    Route::apiResource('products', Api\V1\ProductController::class);
});

Route::domain('api-v2.' . env('APP_URL'))->group(function () {
    Route::apiResource('products', Api\V2\ProductController::class);
});
            

Resource Versioning

You can also version your API Resources:


// app/Http/Resources/V1/ProductResource.php
// app/Http/Resources/V2/ProductResource.php

// In controllers
namespace App\Http\Controllers\Api\V1;

use App\Http\Resources\V1\ProductResource;

class ProductController extends Controller
{
    public function show(Product $product)
    {
        return new ProductResource($product);
    }
}
            

Versioning is like offering different editions of a book - the core content might be similar, but each version has specific features or changes that cater to different audiences.

Real-World Example: E-commerce API

Let's implement a comprehensive e-commerce API with the concepts we've covered:

API Structure


// routes/api.php
Route::prefix('v1')->namespace('App\Http\Controllers\Api\V1')->group(function () {
    // Public endpoints
    Route::get('products', [ProductController::class, 'index']);
    Route::get('products/{product:slug}', [ProductController::class, 'show']);
    Route::get('categories', [CategoryController::class, 'index']);
    Route::get('categories/{category:slug}', [CategoryController::class, 'show']);
    
    // Authentication endpoints
    Route::post('auth/register', [AuthController::class, 'register']);
    Route::post('auth/login', [AuthController::class, 'login']);
    
    // Protected endpoints
    Route::middleware('auth:sanctum')->group(function () {
        // User endpoints
        Route::get('user', [UserController::class, 'show']);
        Route::put('user', [UserController::class, 'update']);
        
        // Cart endpoints
        Route::get('cart', [CartController::class, 'show']);
        Route::post('cart/items', [CartController::class, 'addItem']);
        Route::put('cart/items/{id}', [CartController::class, 'updateItem']);
        Route::delete('cart/items/{id}', [CartController::class, 'removeItem']);
        
        // Order endpoints
        Route::get('orders', [OrderController::class, 'index']);
        Route::post('orders', [OrderController::class, 'store']);
        Route::get('orders/{order}', [OrderController::class, 'show']);
        
        // Checkout endpoint
        Route::post('checkout', [CheckoutController::class, 'process']);
        
        // Admin routes
        Route::middleware('can:manage-products')->group(function () {
            Route::apiResource('admin/products', AdminProductController::class);
            Route::apiResource('admin/categories', AdminCategoryController::class);
            Route::get('admin/orders', [AdminOrderController::class, 'index']);
            Route::put('admin/orders/{order}/status', [AdminOrderController::class, 'updateStatus']);
        });
    });
});
            

Product Controller and Resource


// app/Http/Controllers/Api/V1/ProductController.php
namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Http\Resources\V1\ProductResource;
use App\Http\Resources\V1\ProductCollection;
use Illuminate\Http\Request;

class ProductController extends Controller
{
    public function index(Request $request)
    {
        $query = Product::query()
            ->with(['category', 'tags'])
            ->where('is_active', true);
        
        // Handle category filter
        if ($request->has('category')) {
            $category = $request->input('category');
            $query->whereHas('category', function ($q) use ($category) {
                $q->where('slug', $category);
            });
        }
        
        // Handle price filters
        if ($request->has('price_min')) {
            $query->where('price', '>=', $request->input('price_min'));
        }
        
        if ($request->has('price_max')) {
            $query->where('price', '<=', $request->input('price_max'));
        }
        
        // Handle search
        if ($request->has('search')) {
            $search = $request->input('search');
            $query->where(function ($q) use ($search) {
                $q->where('name', 'like', "%{$search}%")
                  ->orWhere('description', 'like', "%{$search}%");
            });
        }
        
        // Handle sorting
        $sortField = $request->input('sort', 'created_at');
        $sortDirection = $request->input('direction', 'desc');
        
        $allowedSortFields = ['name', 'price', 'created_at'];
        if (in_array($sortField, $allowedSortFields)) {
            $query->orderBy($sortField, $sortDirection === 'asc' ? 'asc' : 'desc');
        } else {
            $query->orderBy('created_at', 'desc');
        }
        
        $perPage = min($request->input('per_page', 15), 50); // Limit max per page
        $products = $query->paginate($perPage);
        
        return new ProductCollection($products);
    }
    
    public function show(Product $product)
    {
        if (!$product->is_active) {
            return response()->json([
                'status' => 'error',
                'message' => 'Product not found'
            ], 404);
        }
        
        $product->load(['category', 'tags', 'reviews.user']);
        
        return new ProductResource($product);
    }
}

// app/Http/Resources/V1/ProductResource.php
namespace App\Http\Resources\V1;

use Illuminate\Http\Resources\Json\JsonResource;

class ProductResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'slug' => $this->slug,
            'description' => $this->description,
            'price' => [
                'amount' => $this->price,
                'currency' => 'USD',
                'formatted' => '$' . number_format($this->price, 2)
            ],
            'discount' => $this->when($this->discount_percentage > 0, [
                'percentage' => $this->discount_percentage,
                'amount' => $this->price * ($this->discount_percentage / 100),
                'final_price' => $this->price - ($this->price * ($this->discount_percentage / 100)),
                'formatted_final_price' => '$' . number_format($this->price - ($this->price * ($this->discount_percentage / 100)), 2)
            ]),
            'category' => new CategoryResource($this->whenLoaded('category')),
            'tags' => TagResource::collection($this->whenLoaded('tags')),
            'images' => [
                'thumbnail' => $this->thumbnail_url,
                'main' => $this->main_image_url,
                'gallery' => $this->when($this->relationLoaded('images'), function () {
                    return $this->images->map(function ($image) {
                        return [
                            'id' => $image->id,
                            'url' => $image->url,
                            'alt' => $image->alt
                        ];
                    });
                })
            ],
            'stock' => [
                'quantity' => $this->stock_quantity,
                'in_stock' => $this->stock_quantity > 0,
                'status' => $this->stock_quantity > 0 ? 'In Stock' : 'Out of Stock'
            ],
            'ratings' => $this->when($this->relationLoaded('reviews'), [
                'average' => round($this->reviews->avg('rating'), 1),
                'count' => $this->reviews->count()
            ]),
            'reviews' => $this->when($this->relationLoaded('reviews'), function () {
                return ReviewResource::collection($this->reviews);
            }),
            'created_at' => $this->created_at->toIso8601String(),
            'updated_at' => $this->updated_at->toIso8601String(),
        ];
    }
}
            

Authentication Controller


// app/Http/Controllers/Api/V1/AuthController.php
namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\User;
use App\Http\Requests\Api\LoginRequest;
use App\Http\Requests\Api\RegisterRequest;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Hash;

class AuthController extends Controller
{
    public function register(RegisterRequest $request)
    {
        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
        ]);
        
        $token = $user->createToken('api-token')->plainTextToken;
        
        return response()->json([
            'status' => 'success',
            'message' => 'User registered successfully',
            'data' => [
                'user' => [
                    'id' => $user->id,
                    'name' => $user->name,
                    'email' => $user->email,
                ],
                'token' => $token
            ]
        ], Response::HTTP_CREATED);
    }
    
    public function login(LoginRequest $request)
    {
        $user = User::where('email', $request->email)->first();
        
        if (!$user || !Hash::check($request->password, $user->password)) {
            return response()->json([
                'status' => 'error',
                'message' => 'Invalid credentials'
            ], Response::HTTP_UNAUTHORIZED);
        }
        
        // Revoke old tokens
        $user->tokens()->delete();
        
        $token = $user->createToken('api-token')->plainTextToken;
        
        return response()->json([
            'status' => 'success',
            'message' => 'User logged in successfully',
            'data' => [
                'user' => [
                    'id' => $user->id,
                    'name' => $user->name,
                    'email' => $user->email,
                ],
                'token' => $token
            ]
        ]);
    }
    
    public function logout(Request $request)
    {
        $request->user()->currentAccessToken()->delete();
        
        return response()->json([
            'status' => 'success',
            'message' => 'User logged out successfully'
        ]);
    }
}
            

This example demonstrates several important aspects of API development:

This pattern can be adapted for many types of APIs, providing a solid foundation for your API development.

API Documentation

Good documentation is crucial for APIs. Laravel integrates well with several documentation tools:

Documenting with OpenAPI/Swagger

You can use packages like darkaonline/l5-swagger to generate OpenAPI documentation from annotations:


/**
 * @OA\Info(
 *     title="E-commerce API",
 *     version="1.0.0",
 *     description="API for e-commerce application"
 * )
 */

/**
 * @OA\Get(
 *     path="/api/v1/products",
 *     summary="Get all products",
 *     tags={"Products"},
 *     @OA\Parameter(
 *         name="search",
 *         in="query",
 *         description="Search term",
 *         required=false,
 *         @OA\Schema(type="string")
 *     ),
 *     @OA\Parameter(
 *         name="category",
 *         in="query",
 *         description="Filter by category slug",
 *         required=false,
 *         @OA\Schema(type="string")
 *     ),
 *     @OA\Response(
 *         response=200,
 *         description="List of products",
 *         @OA\JsonContent(
 *             type="object",
 *             @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Product")),
 *             @OA\Property(property="links", type="object"),
 *             @OA\Property(property="meta", type="object")
 *         )
 *     )
 * )
 */
public function index(Request $request)
{
    // Method implementation
}
            

Documenting with Scribe

Another option is the knuckleswtf/scribe package, which can automatically generate documentation from your routes and controllers:


/**
 * Get a list of products.
 *
 * @queryParam search string Search term for filtering products. Example: wireless
 * @queryParam category string Category slug for filtering products. Example: electronics
 * @queryParam price_min number Minimum price filter. Example: 10
 * @queryParam price_max number Maximum price filter. Example: 100
 * @queryParam sort string Field to sort by. Allowed values: name, price, created_at. Example: price
 * @queryParam direction string Sort direction. Allowed values: asc, desc. Example: asc
 * @queryParam per_page integer Number of items per page. Example: 15
 *
 * @response {
 *  "data": [
 *    {
 *      "id": 1,
 *      "name": "Wireless Headphones",
 *      "slug": "wireless-headphones",
 *      "price": {
 *        "amount": 149.99,
 *        "currency": "USD",
 *        "formatted": "$149.99"
 *      },
 *      // Other fields...
 *    }
 *  ],
 *  "links": {},
 *  "meta": {}
 * }
 */
public function index(Request $request)
{
    // Method implementation
}
            

Good documentation is like providing a clear instruction manual with your API - it helps developers understand how to use it effectively and reduces support burden.

API Testing

Testing APIs is crucial for reliability. Laravel's testing tools make it straightforward:


// tests/Feature/Api/ProductApiTest.php
namespace Tests\Feature\Api;

use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ProductApiTest extends TestCase
{
    use RefreshDatabase;
    
    /** @test */
    public function it_returns_a_list_of_products()
    {
        // Create products
        $products = Product::factory()->count(5)->create();
        
        // Make API request
        $response = $this->getJson('/api/v1/products');
        
        // Assert response structure and status
        $response
            ->assertStatus(200)
            ->assertJsonStructure([
                'data' => [
                    '*' => ['id', 'name', 'slug', 'price']
                ],
                'links',
                'meta'
            ]);
            
        // Assert all products are in the response
        $this->assertEquals(5, count($response->json('data')));
    }
    
    /** @test */
    public function it_can_filter_products_by_category()
    {
        // Create categories and products
        $category1 = Category::factory()->create(['name' => 'Electronics']);
        $category2 = Category::factory()->create(['name' => 'Clothing']);
        
        $product1 = Product::factory()->create(['category_id' => $category1->id]);
        $product2 = Product::factory()->create(['category_id' => $category2->id]);
        
        // Make API request with filter
        $response = $this->getJson('/api/v1/products?category=' . $category1->slug);
        
        // Assert only products from the correct category are returned
        $response
            ->assertStatus(200)
            ->assertJsonCount(1, 'data');
            
        $this->assertEquals($product1->id, $response->json('data.0.id'));
    }
    
    /** @test */
    public function it_returns_a_single_product()
    {
        // Create product
        $product = Product::factory()->create();
        
        // Make API request
        $response = $this->getJson('/api/v1/products/' . $product->slug);
        
        // Assert response structure and data
        $response
            ->assertStatus(200)
            ->assertJsonStructure([
                'data' => [
                    'id', 'name', 'slug', 'description', 'price', 'category'
                ]
            ])
            ->assertJsonPath('data.id', $product->id)
            ->assertJsonPath('data.name', $product->name);
    }
    
    /** @test */
    public function it_returns_not_found_for_inactive_product()
    {
        // Create inactive product
        $product = Product::factory()->create(['is_active' => false]);
        
        // Make API request
        $response = $this->getJson('/api/v1/products/' . $product->slug);
        
        // Assert not found
        $response
            ->assertStatus(404)
            ->assertJsonPath('status', 'error')
            ->assertJsonPath('message', 'Product not found');
    }
    
    /** @test */
    public function it_can_create_a_product_with_authentication()
    {
        // Create admin user
        $admin = User::factory()->create(['role' => 'admin']);
        
        // Product data
        $productData = [
            'name' => 'New Product',
            'description' => 'Product description',
            'price' => 99.99,
            'category_id' => Category::factory()->create()->id,
            'is_active' => true
        ];
        
        // Make authenticated API request
        $response = $this->actingAs($admin)
            ->postJson('/api/v1/admin/products', $productData);
        
        // Assert product was created
        $response
            ->assertStatus(201)
            ->assertJsonPath('status', 'success')
            ->assertJsonPath('data.name', 'New Product');
            
        // Assert product exists in database
        $this->assertDatabaseHas('products', [
            'name' => 'New Product',
            'price' => 99.99
        ]);
    }
    
    /** @test */
    public function it_prevents_unauthorized_product_creation()
    {
        // Create regular user
        $user = User::factory()->create(['role' => 'user']);
        
        // Product data
        $productData = [
            'name' => 'New Product',
            'description' => 'Product description',
            'price' => 99.99,
            'category_id' => Category::factory()->create()->id
        ];
        
        // Make authenticated API request
        $response = $this->actingAs($user)
            ->postJson('/api/v1/admin/products', $productData);
        
        // Assert forbidden
        $response->assertStatus(403);
            
        // Assert product was not created
        $this->assertDatabaseMissing('products', [
            'name' => 'New Product'
        ]);
    }
}
            

Comprehensive API tests ensure that your endpoints work correctly and continue to work as expected when you make changes. Think of API tests as a safety net that catches issues before they reach your users.

Best Practices for API Development

Practice Activity

Basic API Exercise

Create a simple RESTful API for a blog with the following requirements:

  1. Endpoints for listing, creating, viewing, updating, and deleting blog posts
  2. Proper validation for all inputs
  3. Consistent JSON response format
  4. API Resources for transforming blog post data
  5. Support for filtering posts by category and author
  6. Pagination for post listings

Advanced API Challenge

Extend the blog API with more advanced features:

  1. Authentication using Laravel Sanctum
  2. Role-based authorization (admin vs. author vs. reader)
  3. Nested resources (posts with comments)
  4. Versioning (v1 and v2 with different response formats)
  5. Advanced filtering, sorting, and searching
  6. Rate limiting with custom limits for different user types
  7. Comprehensive API documentation using OpenAPI/Swagger

API Testing Project

Create a comprehensive test suite for your API:

  1. Feature tests for all API endpoints
  2. Test various response scenarios (success, validation errors, not found, unauthorized)
  3. Test filtering, sorting, and pagination
  4. Test authentication and authorization
  5. Setup a CI/CD pipeline to run tests automatically

Summary

In the next lecture, we'll explore API authentication and authorization in Laravel, with a focus on Laravel Sanctum and advanced authentication patterns.