REST API Development
Building a Complete RESTful API - Part 2
Building a Complete RESTful API - Part 2
In Part 2, we'll implement authentication with JWT, create repositories and actions, build CRUD endpoints for products and categories, and handle file uploads for product images.
Setting Up JWT Authentication
First, install the JWT package:
Install tymon/jwt-auth:
composer require tymon/jwt-auth
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
php artisan jwt:secret
config/auth.php - Configure JWT Guard:
<?php
return [
'defaults' => [
'guard' => 'api',
'passwords' => 'users',
],
'guards' => [
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],
],
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => App\Models\User::class,
],
],
];
app/Models/User.php - Implement JWTSubject:
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Tymon\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
{
protected $fillable = [
'name',
'email',
'password',
'phone',
'role',
];
protected $hidden = [
'password',
'remember_token',
];
protected $casts = [
'email_verified_at' => 'datetime',
];
/**
* Get JWT identifier
*/
public function getJWTIdentifier()
{
return $this->getKey();
}
/**
* Get JWT custom claims
*/
public function getJWTCustomClaims()
{
return [
'role' => $this->role,
];
}
/**
* Check if user is admin
*/
public function isAdmin(): bool
{
return $this->role === 'admin';
}
}
Authentication Controller
app/Http/Controllers/Api/AuthController.php:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\RegisterRequest;
use App\Http\Requests\LoginRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
class AuthController extends Controller
{
/**
* Register new user
*/
public function register(RegisterRequest $request): JsonResponse
{
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'phone' => $request->phone,
]);
$token = auth()->login($user);
return $this->respondWithToken($token, $user, 201);
}
/**
* Login user
*/
public function login(LoginRequest $request): JsonResponse
{
$credentials = $request->only(['email', 'password']);
if (!$token = auth()->attempt($credentials)) {
return response()->json([
'message' => 'Invalid credentials',
], 401);
}
return $this->respondWithToken($token, auth()->user());
}
/**
* Get authenticated user
*/
public function me(): UserResource
{
return new UserResource(auth()->user());
}
/**
* Logout user
*/
public function logout(): JsonResponse
{
auth()->logout();
return response()->json([
'message' => 'Successfully logged out',
]);
}
/**
* Refresh token
*/
public function refresh(): JsonResponse
{
$token = auth()->refresh();
return $this->respondWithToken($token, auth()->user());
}
/**
* Respond with token
*/
protected function respondWithToken(string $token, User $user, int $status = 200): JsonResponse
{
return response()->json([
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => auth()->factory()->getTTL() * 60,
'user' => new UserResource($user),
], $status);
}
}
Form Requests for Validation
app/Http/Requests/RegisterRequest.php:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class RegisterRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
'phone' => ['nullable', 'string', 'max:20'],
];
}
}
app/Http/Requests/StoreProductRequest.php:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreProductRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->user()?->isAdmin();
}
public function rules(): array
{
return [
'category_id' => ['required', 'exists:categories,id'],
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'unique:products'],
'description' => ['nullable', 'string'],
'price' => ['required', 'numeric', 'min:0'],
'sale_price' => ['nullable', 'numeric', 'min:0', 'lt:price'],
'sku' => ['required', 'string', 'unique:products'],
'stock_quantity' => ['required', 'integer', 'min:0'],
'is_active' => ['boolean'],
'is_featured' => ['boolean'],
'images' => ['nullable', 'array', 'max:5'],
'images.*' => ['image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
];
}
}
Product Repository
app/Repositories/ProductRepository.php:
<?php
namespace App\Repositories;
use App\Models\Product;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
class ProductRepository
{
/**
* Get paginated products with filters
*/
public function paginate(array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$query = Product::with(['category', 'images']);
// Filter by category
if (!empty($filters['category_id'])) {
$query->where('category_id', $filters['category_id']);
}
// Filter by active status
if (!empty($filters['is_active'])) {
$query->active();
}
// Filter by featured
if (!empty($filters['is_featured'])) {
$query->featured();
}
// Search by name or description
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where(function($q) use ($search) {
$q->where('name', 'LIKE', "%{$search}%")
->orWhere('description', 'LIKE', "%{$search}%");
});
}
// Price range
if (!empty($filters['min_price'])) {
$query->where('price', '>=', $filters['min_price']);
}
if (!empty($filters['max_price'])) {
$query->where('price', '<=', $filters['max_price']);
}
// Sorting
$sortBy = $filters['sort_by'] ?? 'created_at';
$sortOrder = $filters['sort_order'] ?? 'desc';
$query->orderBy($sortBy, $sortOrder);
return $query->paginate($perPage);
}
/**
* Find product with relationships
*/
public function findWithRelations(int $id): ?Product
{
return Product::with(['category', 'images', 'reviews.user'])
->find($id);
}
/**
* Create product
*/
public function create(array $data): Product
{
return Product::create($data);
}
/**
* Update product
*/
public function update(Product $product, array $data): bool
{
return $product->update($data);
}
/**
* Delete product
*/
public function delete(Product $product): bool
{
return $product->delete();
}
/**
* Get featured products
*/
public function getFeatured(int $limit = 10): Collection
{
return Product::with(['category', 'images'])
->active()
->featured()
->inStock()
->limit($limit)
->get();
}
}
Image Upload Service
app/Services/ImageUploadService.php:
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Facades\Image;
class ImageUploadService
{
/**
* Upload and resize image
*/
public function upload(
UploadedFile $file,
string $directory = 'products',
int $width = 800,
int $height = 800
): string {
// Generate unique filename
$filename = time() . '_' . uniqid() . '.' . $file->extension();
$path = "{$directory}/{$filename}";
// Resize and optimize image
$image = Image::make($file)
->fit($width, $height, function ($constraint) {
$constraint->upsize();
})
->encode($file->extension(), 80);
// Store image
Storage::disk('public')->put($path, $image);
return $path;
}
/**
* Upload multiple images
*/
public function uploadMultiple(
array $files,
string $directory = 'products'
): array {
$paths = [];
foreach ($files as $file) {
$paths[] = $this->upload($file, $directory);
}
return $paths;
}
/**
* Delete image
*/
public function delete(string $path): bool
{
if (Storage::disk('public')->exists($path)) {
return Storage::disk('public')->delete($path);
}
return false;
}
/**
* Delete multiple images
*/
public function deleteMultiple(array $paths): void
{
foreach ($paths as $path) {
$this->delete($path);
}
}
}
Product CRUD Endpoints
app/Http/Controllers/Api/ProductController.php:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\UpdateProductRequest;
use App\Http\Resources\ProductResource;
use App\Models\Product;
use App\Repositories\ProductRepository;
use App\Services\ImageUploadService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class ProductController extends Controller
{
public function __construct(
private ProductRepository $productRepository,
private ImageUploadService $imageUploadService
) {
$this->middleware('auth:api')->except(['index', 'show']);
$this->middleware('admin')->only(['store', 'update', 'destroy']);
}
/**
* Display a listing of products
*/
public function index(Request $request): AnonymousResourceCollection
{
$filters = $request->only([
'category_id',
'is_active',
'is_featured',
'search',
'min_price',
'max_price',
'sort_by',
'sort_order',
]);
$perPage = $request->input('per_page', 15);
$products = $this->productRepository->paginate($filters, $perPage);
return ProductResource::collection($products);
}
/**
* Display the specified product
*/
public function show(int $id): ProductResource|JsonResponse
{
$product = $this->productRepository->findWithRelations($id);
if (!$product) {
return response()->json([
'message' => 'Product not found',
], 404);
}
return new ProductResource($product);
}
/**
* Store a newly created product
*/
public function store(StoreProductRequest $request): JsonResponse
{
$data = $request->except('images');
// Create product
$product = $this->productRepository->create($data);
// Upload images if provided
if ($request->hasFile('images')) {
$this->uploadProductImages($product, $request->file('images'));
}
return (new ProductResource($product->load(['category', 'images'])))
->response()
->setStatusCode(201);
}
/**
* Update the specified product
*/
public function update(UpdateProductRequest $request, Product $product): ProductResource
{
$data = $request->except('images');
$this->productRepository->update($product, $data);
// Upload new images if provided
if ($request->hasFile('images')) {
$this->uploadProductImages($product, $request->file('images'));
}
return new ProductResource($product->fresh(['category', 'images']));
}
/**
* Remove the specified product
*/
public function destroy(Product $product): JsonResponse
{
// Delete product images
$imagePaths = $product->images->pluck('image_path')->toArray();
$this->imageUploadService->deleteMultiple($imagePaths);
// Delete product
$this->productRepository->delete($product);
return response()->json([
'message' => 'Product deleted successfully',
]);
}
/**
* Upload product images
*/
private function uploadProductImages(Product $product, array $images): void
{
foreach ($images as $index => $image) {
$path = $this->imageUploadService->upload($image, 'products');
$product->images()->create([
'image_path' => $path,
'is_primary' => $index === 0, // First image is primary
'order' => $index,
]);
}
}
}
API Resources for Response Formatting
app/Http/Resources/ProductResource.php:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProductResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'category' => new CategoryResource($this->whenLoaded('category')),
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
'price' => (float) $this->price,
'sale_price' => $this->sale_price ? (float) $this->sale_price : null,
'current_price' => (float) $this->current_price,
'sku' => $this->sku,
'stock_quantity' => $this->stock_quantity,
'in_stock' => $this->in_stock,
'is_active' => $this->is_active,
'is_featured' => $this->is_featured,
'images' => ProductImageResource::collection($this->whenLoaded('images')),
'reviews_count' => $this->whenCounted('reviews'),
'average_rating' => $this->when(
$this->relationLoaded('reviews'),
fn() => round($this->reviews->avg('rating'), 1)
),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
];
}
}
app/Http/Resources/ProductImageResource.php:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
class ProductImageResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'url' => Storage::disk('public')->url($this->image_path),
'is_primary' => $this->is_primary,
'order' => $this->order,
];
}
}
API Routes
routes/api.php:
<?php
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\ProductController;
use App\Http\Controllers\Api\CategoryController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () {
// Authentication routes
Route::prefix('auth')->group(function () {
Route::post('register', [AuthController::class, 'register']);
Route::post('login', [AuthController::class, 'login']);
Route::post('logout', [AuthController::class, 'logout'])->middleware('auth:api');
Route::post('refresh', [AuthController::class, 'refresh'])->middleware('auth:api');
Route::get('me', [AuthController::class, 'me'])->middleware('auth:api');
});
// Product routes
Route::apiResource('products', ProductController::class);
// Category routes
Route::apiResource('categories', CategoryController::class);
});
Admin Middleware
app/Http/Middleware/AdminMiddleware.php:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class AdminMiddleware
{
public function handle(Request $request, Closure $next)
{
if (!auth()->check() || !auth()->user()->isAdmin()) {
return response()->json([
'message' => 'Unauthorized. Admin access required.',
], 403);
}
return $next($request);
}
}
Security Note: Always validate file uploads - check file type, size, and scan for malware in production. Store uploaded files outside the web root or use cloud storage with proper access controls.
Practice Exercise:
- Implement the CategoryController with full CRUD
- Create the Cart and CartItem controllers
- Build the Order checkout flow
- Add product review endpoints
- Implement pagination, filtering, and sorting for all list endpoints
Summary
In Part 2, we've implemented:
- JWT authentication with login, register, and token refresh
- Repository pattern for data access
- Image upload service with resizing and optimization
- Complete CRUD operations for products
- API Resources for consistent response formatting
- Form Request validation
- Admin middleware for authorization
In Part 3, we'll add comprehensive testing, generate API documentation, deploy to production, and review best practices.