REST API Development
Building a Complete RESTful API - Part 1
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:
- Run the migrations to create all tables
- Create the remaining models (Cart, Order, Review, etc.)
- Define all relationships between models
- Add appropriate scopes and accessors to models
- 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.