REST API Development

Building a Complete RESTful API - Part 1

20 min Lesson 33 of 35

Building a Complete RESTful API - Part 1

In this three-part series, we'll build a complete e-commerce API from scratch, applying all the patterns and best practices we've learned. Part 1 focuses on planning, database design, and setting up the foundation.

Project Overview

We'll build an API for an e-commerce platform with the following features:

  • User Management: Registration, authentication, profiles
  • Product Catalog: Products, categories, tags, search
  • Shopping Cart: Add/remove items, quantities
  • Orders: Checkout, order history, status tracking
  • Reviews: Product ratings and reviews
  • Admin Panel: Manage products, orders, users

Planning: API Endpoints

API Design Principle: Design your API endpoints before writing code. Think about resources, relationships, and operations. Document everything.

Let's map out our API endpoints:

Authentication Endpoints:
POST   /api/v1/auth/register          # Register new user
POST   /api/v1/auth/login             # Login user
POST   /api/v1/auth/logout            # Logout user
POST   /api/v1/auth/refresh           # Refresh token
GET    /api/v1/auth/me                # Get authenticated user
PUT    /api/v1/auth/profile           # Update profile
POST   /api/v1/auth/password/forgot   # Forgot password
POST   /api/v1/auth/password/reset    # Reset password
Product Endpoints:
GET    /api/v1/products               # List products (with filters)
GET    /api/v1/products/{id}          # Get product details
POST   /api/v1/products               # Create product (admin)
PUT    /api/v1/products/{id}          # Update product (admin)
DELETE /api/v1/products/{id}          # Delete product (admin)
GET    /api/v1/products/{id}/reviews  # Get product reviews
POST   /api/v1/products/{id}/reviews  # Add review
Category Endpoints:
GET    /api/v1/categories             # List all categories
GET    /api/v1/categories/{id}        # Get category details
POST   /api/v1/categories             # Create category (admin)
PUT    /api/v1/categories/{id}        # Update category (admin)
DELETE /api/v1/categories/{id}        # Delete category (admin)
GET    /api/v1/categories/{id}/products # Products in category
Cart Endpoints:
GET    /api/v1/cart                   # Get user cart
POST   /api/v1/cart/items             # Add item to cart
PUT    /api/v1/cart/items/{id}        # Update cart item
DELETE /api/v1/cart/items/{id}        # Remove from cart
DELETE /api/v1/cart                   # Clear cart
Order Endpoints:
GET    /api/v1/orders                 # List user orders
GET    /api/v1/orders/{id}            # Get order details
POST   /api/v1/orders                 # Create order (checkout)
PUT    /api/v1/orders/{id}/cancel     # Cancel order
GET    /api/v1/admin/orders           # List all orders (admin)
PUT    /api/v1/admin/orders/{id}      # Update order status (admin)

Database Design

Let's design our database schema with relationships:

Users Table:
users
├── id (primary key)
├── name
├── email (unique)
├── password
├── phone
├── role (enum: user, admin)
├── email_verified_at
├── created_at
└── updated_at
Categories Table:
categories
├── id (primary key)
├── name
├── slug (unique)
├── description
├── image
├── parent_id (nullable, self-reference)
├── is_active
├── created_at
└── updated_at
Products Table:
products
├── id (primary key)
├── category_id (foreign key)
├── name
├── slug (unique)
├── description
├── price (decimal)
├── sale_price (decimal, nullable)
├── sku (unique)
├── stock_quantity
├── is_active
├── is_featured
├── created_at
└── updated_at
Product Images Table:
product_images
├── id (primary key)
├── product_id (foreign key)
├── image_path
├── is_primary
├── order
├── created_at
└── updated_at
Carts Table:
carts
├── id (primary key)
├── user_id (foreign key, nullable)
├── session_id (for guest carts)
├── created_at
└── updated_at

cart_items
├── id (primary key)
├── cart_id (foreign key)
├── product_id (foreign key)
├── quantity
├── price (snapshot at time of adding)
├── created_at
└── updated_at
Orders Table:
orders
├── id (primary key)
├── user_id (foreign key)
├── order_number (unique)
├── status (enum: pending, processing, completed, cancelled)
├── subtotal
├── tax
├── shipping
├── total
├── payment_method
├── payment_status
├── shipping_address (JSON)
├── billing_address (JSON)
├── notes
├── created_at
└── updated_at

order_items
├── id (primary key)
├── order_id (foreign key)
├── product_id (foreign key)
├── product_name (snapshot)
├── quantity
├── price (snapshot)
├── subtotal
├── created_at
└── updated_at
Reviews Table:
reviews
├── id (primary key)
├── product_id (foreign key)
├── user_id (foreign key)
├── rating (1-5)
├── title
├── comment
├── is_verified_purchase
├── is_approved
├── created_at
└── updated_at

Creating Migrations

Let's create our database migrations:

Create Users Migration:
<?php
# Already exists in Laravel

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->string('phone')->nullable();
            $table->enum('role', ['user', 'admin'])->default('user');
            $table->rememberToken();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('users');
    }
};
database/migrations/xxxx_create_categories_table.php:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('categories', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description')->nullable();
            $table->string('image')->nullable();
            $table->foreignId('parent_id')
                ->nullable()
                ->constrained('categories')
                ->nullOnDelete();
            $table->boolean('is_active')->default(true);
            $table->timestamps();

            $table->index(['slug', 'is_active']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('categories');
    }
};
database/migrations/xxxx_create_products_table.php:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->foreignId('category_id')
                ->constrained()
                ->cascadeOnDelete();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description')->nullable();
            $table->decimal('price', 10, 2);
            $table->decimal('sale_price', 10, 2)->nullable();
            $table->string('sku')->unique();
            $table->integer('stock_quantity')->default(0);
            $table->boolean('is_active')->default(true);
            $table->boolean('is_featured')->default(false);
            $table->timestamps();

            $table->index(['slug', 'is_active']);
            $table->index(['category_id', 'is_active']);
        });

        Schema::create('product_images', function (Blueprint $table) {
            $table->id();
            $table->foreignId('product_id')
                ->constrained()
                ->cascadeOnDelete();
            $table->string('image_path');
            $table->boolean('is_primary')->default(false);
            $table->integer('order')->default(0);
            $table->timestamps();

            $table->index(['product_id', 'is_primary']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('product_images');
        Schema::dropIfExists('products');
    }
};
database/migrations/xxxx_create_carts_table.php:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('carts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')
                ->nullable()
                ->constrained()
                ->cascadeOnDelete();
            $table->string('session_id')->nullable()->index();
            $table->timestamps();
        });

        Schema::create('cart_items', function (Blueprint $table) {
            $table->id();
            $table->foreignId('cart_id')
                ->constrained()
                ->cascadeOnDelete();
            $table->foreignId('product_id')
                ->constrained()
                ->cascadeOnDelete();
            $table->integer('quantity')->default(1);
            $table->decimal('price', 10, 2);
            $table->timestamps();

            $table->unique(['cart_id', 'product_id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('cart_items');
        Schema::dropIfExists('carts');
    }
};
database/migrations/xxxx_create_orders_table.php:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')
                ->constrained()
                ->cascadeOnDelete();
            $table->string('order_number')->unique();
            $table->enum('status', [
                'pending',
                'processing',
                'completed',
                'cancelled'
            ])->default('pending');
            $table->decimal('subtotal', 10, 2);
            $table->decimal('tax', 10, 2)->default(0);
            $table->decimal('shipping', 10, 2)->default(0);
            $table->decimal('total', 10, 2);
            $table->string('payment_method');
            $table->string('payment_status')->default('pending');
            $table->json('shipping_address');
            $table->json('billing_address');
            $table->text('notes')->nullable();
            $table->timestamps();

            $table->index(['user_id', 'status']);
            $table->index('order_number');
        });

        Schema::create('order_items', function (Blueprint $table) {
            $table->id();
            $table->foreignId('order_id')
                ->constrained()
                ->cascadeOnDelete();
            $table->foreignId('product_id')
                ->constrained();
            $table->string('product_name'); // Snapshot
            $table->integer('quantity');
            $table->decimal('price', 10, 2);
            $table->decimal('subtotal', 10, 2);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('order_items');
        Schema::dropIfExists('orders');
    }
};
database/migrations/xxxx_create_reviews_table.php:
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('reviews', function (Blueprint $table) {
            $table->id();
            $table->foreignId('product_id')
                ->constrained()
                ->cascadeOnDelete();
            $table->foreignId('user_id')
                ->constrained()
                ->cascadeOnDelete();
            $table->tinyInteger('rating')->unsigned();
            $table->string('title');
            $table->text('comment')->nullable();
            $table->boolean('is_verified_purchase')->default(false);
            $table->boolean('is_approved')->default(false);
            $table->timestamps();

            $table->index(['product_id', 'is_approved']);
            $table->unique(['product_id', 'user_id']); // One review per user per product
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('reviews');
    }
};

Creating Models

Now let's create our Eloquent models with relationships:

app/Models/Category.php:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Category extends Model
{
    protected $fillable = [
        'name',
        'slug',
        'description',
        'image',
        'parent_id',
        'is_active',
    ];

    protected $casts = [
        'is_active' => 'boolean',
    ];

    /**
     * Parent category
     */
    public function parent(): BelongsTo
    {
        return $this->belongsTo(Category::class, 'parent_id');
    }

    /**
     * Child categories
     */
    public function children(): HasMany
    {
        return $this->hasMany(Category::class, 'parent_id');
    }

    /**
     * Products in this category
     */
    public function products(): HasMany
    {
        return $this->hasMany(Product::class);
    }

    /**
     * Scope to get active categories
     */
    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }

    /**
     * Scope to get root categories
     */
    public function scopeRoot($query)
    {
        return $query->whereNull('parent_id');
    }
}
app/Models/Product.php:
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Product extends Model
{
    protected $fillable = [
        'category_id',
        'name',
        'slug',
        'description',
        'price',
        'sale_price',
        'sku',
        'stock_quantity',
        'is_active',
        'is_featured',
    ];

    protected $casts = [
        'price' => 'decimal:2',
        'sale_price' => 'decimal:2',
        'is_active' => 'boolean',
        'is_featured' => 'boolean',
    ];

    protected $appends = ['current_price', 'in_stock'];

    /**
     * Category relationship
     */
    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

    /**
     * Images relationship
     */
    public function images(): HasMany
    {
        return $this->hasMany(ProductImage::class);
    }

    /**
     * Reviews relationship
     */
    public function reviews(): HasMany
    {
        return $this->hasMany(Review::class);
    }

    /**
     * Get current price (sale price if available)
     */
    public function getCurrentPriceAttribute(): float
    {
        return $this->sale_price ?? $this->price;
    }

    /**
     * Check if product is in stock
     */
    public function getInStockAttribute(): bool
    {
        return $this->stock_quantity > 0;
    }

    /**
     * Scope active products
     */
    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }

    /**
     * Scope featured products
     */
    public function scopeFeatured($query)
    {
        return $query->where('is_featured', true);
    }

    /**
     * Scope in stock products
     */
    public function scopeInStock($query)
    {
        return $query->where('stock_quantity', '>', 0);
    }
}
Migration Best Practice: Always create indexes on foreign keys and columns you frequently query. Use appropriate column types (decimal for money, enum for status fields). Add unique constraints where needed.
Practice Exercise:
  1. Run the migrations to create all tables
  2. Create the remaining models (Cart, Order, Review, etc.)
  3. Define all relationships between models
  4. Add appropriate scopes and accessors to models
  5. Create a seeder to populate test data

Summary

In Part 1, we've:

  • Planned our API endpoints and resources
  • Designed a normalized database schema
  • Created migrations with proper indexes and constraints
  • Built Eloquent models with relationships
  • Added scopes and accessors for common queries

In Part 2, we'll implement authentication, create repositories and actions, build CRUD endpoints, and handle file uploads for product images.