REST API Development

Building a Complete RESTful API - Part 2

18 min Lesson 34 of 35

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:
  1. Implement the CategoryController with full CRUD
  2. Create the Cart and CartItem controllers
  3. Build the Order checkout flow
  4. Add product review endpoints
  5. 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.