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.
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
- Resource-Based: Everything is a resource identified by a URL (e.g.,
/users,/products/5) - Stateless: Each request contains all information needed to complete it
- Standard HTTP Methods: Using GET, POST, PUT, PATCH, DELETE for operations
- Representation: Resources can have different representations (JSON, XML, etc.)
- Uniform Interface: Consistent approach to interaction with resources
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:
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:
- Consistent Response Structure: Using a standard format for all responses
- Form Requests: Using dedicated form request classes for validation
- HTTP Status Codes: Returning appropriate status codes
- Route Model Binding: Automatic resolution of route parameters
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:
/api/products?category_id=5&min_price=50&max_price=100/api/products?search=wireless&sort_by=price&sort_dir=asc/api/products?is_active=1&per_page=50
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:
- Structured Routes: Organizing routes by version and access level
- Resource Transformations: Detailed product resource with nested data
- Filtering and Sorting: Comprehensive query parameters
- Authentication: Token-based auth with Laravel Sanctum
- Form Requests: Dedicated request classes for validation
- Consistent Response Format: Standardized JSON structure
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
- Use Proper HTTP Methods: GET for reading, POST for creating, PUT/PATCH for updating, DELETE for removing
- Return Appropriate Status Codes: Use standard HTTP status codes consistently
- Use API Resources: Transform your data with API Resources for cleaner, more consistent responses
- Validate Input: Always validate input data with Form Requests
- Use Route Model Binding: Let Laravel handle resource retrieval
- Implement Pagination: Always paginate collections of resources
- Support Filtering and Sorting: Allow API consumers to filter and sort data
- Consistent Response Format: Maintain a consistent structure for all responses
- Proper Error Handling: Return descriptive error messages and appropriate status codes
- Version Your API: Use versioning to maintain backward compatibility
- Document Your API: Provide comprehensive, up-to-date documentation
- Test Your API: Write tests for all endpoints
- Use API-Specific Middleware: Apply middleware tailored for API requests
- Follow Naming Conventions: Use consistent, descriptive names for endpoints and parameters
- Rate Limiting: Implement rate limiting to prevent abuse
Practice Activity
Basic API Exercise
Create a simple RESTful API for a blog with the following requirements:
- Endpoints for listing, creating, viewing, updating, and deleting blog posts
- Proper validation for all inputs
- Consistent JSON response format
- API Resources for transforming blog post data
- Support for filtering posts by category and author
- Pagination for post listings
Advanced API Challenge
Extend the blog API with more advanced features:
- Authentication using Laravel Sanctum
- Role-based authorization (admin vs. author vs. reader)
- Nested resources (posts with comments)
- Versioning (v1 and v2 with different response formats)
- Advanced filtering, sorting, and searching
- Rate limiting with custom limits for different user types
- Comprehensive API documentation using OpenAPI/Swagger
API Testing Project
Create a comprehensive test suite for your API:
- Feature tests for all API endpoints
- Test various response scenarios (success, validation errors, not found, unauthorized)
- Test filtering, sorting, and pagination
- Test authentication and authorization
- Setup a CI/CD pipeline to run tests automatically
Summary
- RESTful APIs provide a standardized way for systems to communicate
- Laravel offers robust tools for API development, including dedicated routes and controllers
- API Resources transform your models into consistent JSON responses
- Form Requests handle validation and authorization for API endpoints
- Proper response formats and status codes are crucial for API usability
- Features like filtering, sorting, and pagination enhance the API experience
- Versioning helps maintain backward compatibility
- Documentation and testing are essential for reliable APIs
In the next lecture, we'll explore API authentication and authorization in Laravel, with a focus on Laravel Sanctum and advanced authentication patterns.