Project Design — ShopMetrics SaaS Platform Architecture
Coding without a design is like building a house without a blueprint—you don't realize the foundation isn't deep enough until you've reached the third floor, at which point you have no choice but to tear it down and start over.
1. What You'll Learn
- Requirements Analysis and User Stories: Alice (Tenant Administrator)/Bob (Platform Operations)/Charlie (Data Analyst)
- Selecting a Multi-Tenant Architecture: Shared Database vs. Dedicated Database Strategies
- Database ER design: tenants/users/plans/subscriptions/shops/orders/analytics
- API Documentation Design: The OpenAPI 3.0 Specification and Postman Collections
- Technology Selection: Laravel 11 / MySQL / Redis / S3 / WebSocket
2. A True Story of Building a SaaS Business from Scratch
(1) Pain Point: Bob wants to build an e-commerce analytics platform but doesn't know where to start
Bob runs an e-commerce data consulting firm with more than 50 clients. Each client's data is scattered across eight e-commerce platforms, and Bob manually aggregates it in Excel—spending four hours a day creating reports, which are often riddled with errors. Alice, the operations manager at one of these clients, says, "It would be great to have a dashboard that shows all store data in real time." Charlie, Bob's data analyst, says, "We need a multi-tenant SaaS platform, but how should we design the architecture?"
(2) Systematic Design Approach
Start with requirements analysis → user stories → architecture selection → data modeling → API design → technology selection. Each step has clear deliverables, and you follow the blueprint when coding.
Requirements Analysis → User Stories → Architecture Selection → ER Design → API Design → Technology Selection → Start Coding
(3) Revenue
After Bob spent two weeks completing the design, his coding efficiency tripled—because each requirement had a clear interface and database design, eliminating the need to make changes as he went along.
3. Requirements Analysis and User Stories
(1) Three Types of User Profiles
| Role | Name | Core Need | Typical Actions |
|---|---|---|---|
| Tenant Administrator | Alice | Manage your own store and team | Create stores, invite members, view the dashboard |
| Platform Operations | Bob | Manage all tenants and billing | Approve tenants, manage plans, view platform statistics |
| Data Analyst | Charlie | Analyze e-commerce data and generate reports | Create reports, set up alerts, and export data |
(2) User Stories
As Alice (Tenant Admin), I want to:
- US-01: Add a new e-commerce shop so I can track its metrics
- US-02: Invite team members so Charlie can access analytics
- US-03: View a dashboard showing revenue/orders across all my shops
- US-04: Subscribe to a plan that fits my number of shops
- US-05: Export reports in CSV/Excel format
- US-06: Set up alerts when revenue drops below a threshold
As Bob (Platform Operator), I want to:
- US-07: Manage tenant accounts (create/suspend/delete)
- US-08: Define subscription plans with different feature limits
- US-09: View platform-wide metrics (total tenants/MRR/activations)
- US-10: Process subscription payments via Stripe
- US-11: Send notifications to tenants about expiring subscriptions
As Charlie (Data Analyst), I want to:
- US-12: Build custom analytics reports with date range and filters
- US-13: Compare shop performance side by side
- US-14: Schedule automated report generation (daily/weekly/monthly)
- US-15: Receive real-time notifications when significant events occur
(1) ▶ Example: Breaking Down User Stories into Functional Modules
// User stories → Feature modules mapping
return [
'Tenant Management' => [
'US-07: Manage tenant accounts',
'US-01: Add e-commerce shops',
'US-02: Invite team members',
],
'Subscription & Billing' => [
'US-04: Subscribe to a plan',
'US-08: Define subscription plans',
'US-10: Process Stripe payments',
'US-11: Expiring notifications',
],
'Analytics & Reports' => [
'US-03: View revenue dashboard',
'US-05: Export reports',
'US-12: Build custom reports',
'US-13: Compare shop performance',
'US-14: Schedule report generation',
],
'Alerts & Notifications' => [
'US-06: Revenue drop alerts',
'US-15: Real-time event notifications',
],
'Platform Administration' => [
'US-09: Platform-wide metrics',
],
];
Output:
// Execution Successful
4. Selecting a Multitenant Architecture
(1) Three Multitenancy Strategies
| Strategy | Isolation Level | Cost | Complexity | Use Cases |
|---|---|---|---|---|
| Standalone Database | Highest | High | Medium | Financial/Healthcare Compliance Requirements |
| Shared Database + Independent Schema | Medium | Medium | Medium | Medium-sized, with some isolation requirements |
| Shared Database + Shared Schema | Lowest | Low | Low | Most SaaS, tenant_id isolation |
(2) ShopMetrics Selection Decision
flowchart TD
A[Multi-tenant Strategy] --> B{Data Isolation Requirement?}
B -->|Strict compliance| C[Separate DB per Tenant]
B -->|Standard SaaS| D{Tenant Count?}
D -->|< 100| E[Shared DB + Separate Schema]
D -->|> 100| F[Shared DB + Shared Schema]
F --> G[tenant_id on every row]
G --> H[Global Scope auto-filter]
H --> I[ShopMetrics Choice ✅]
ShopMetrics has chosen the shared database + shared schema strategy:
- Expected 500+ tenants; cost-sensitive
- There are no financial compliance requirements; tenant_id isolation is sufficient.
- Implementing Automatic Filtering in Laravel's Global Scope
(1) ▶ Example: Multi-tenant Global Scope Implementation
// app/Models/Traits/BelongsToTenant.php
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $builder) {
$tenantId = Tenant::current()?->id;
if ($tenantId) {
$builder->where('tenant_id', $tenantId);
}
});
static::creating(function (Model $model) {
$tenantId = Tenant::current()?->id;
if ($tenantId && ! $model->isDirty('tenant_id')) {
$model->tenant_id = $tenantId;
}
});
}
}
// app/Models/Tenant.php
class Tenant extends Model
{
protected static Tenant $currentTenant;
public static function setCurrent(self $tenant): void
{
static::$currentTenant = $tenant;
}
public static function current(): ?self
{
return static::$currentTenant ?? null;
}
}
// app/Http/Middleware/SetTenantContext.php
class SetTenantContext
{
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->user()) {
Tenant::setCurrent($user->tenant);
}
return $next($request);
}
}
Output:
// Execution Successful
5. Database ER Design
(1) Core Entity Relationships
erDiagram
TENANT ||--o{ USER : "has many"
TENANT ||--o{ SHOP : "has many"
TENANT ||--|| SUBSCRIPTION : "has one"
PLAN ||--o{ SUBSCRIPTION : "subscribed to"
SHOP ||--o{ ORDER : "has many"
SHOP ||--o{ PRODUCT : "has many"
ORDER ||--|{ ORDER_ITEM : "contains"
ORDER_ITEM }o--|| PRODUCT : "references"
USER ||--o{ REPORT : "creates"
TENANT ||--o{ ALERT : "configures"
TENANT {
bigint id PK
string name
string slug UK
string domain
string status
timestamp created_at
}
USER {
bigint id PK
bigint tenant_id FK
string name
string email UK
string role
timestamp created_at
}
PLAN {
bigint id PK
string name
string slug UK
int shop_limit
int user_limit
int price_cents
string stripe_price_id
}
SUBSCRIPTION {
bigint id PK
bigint tenant_id FK
bigint plan_id FK
string stripe_id
string status
timestamp trial_ends_at
timestamp ends_at
}
SHOP {
bigint id PK
bigint tenant_id FK
string name
string platform
string external_id
string status
}
ORDER {
bigint id PK
bigint tenant_id FK
bigint shop_id FK
string external_id
string customer_email
int total_cents
string status
timestamp ordered_at
}
PRODUCT {
bigint id PK
bigint tenant_id FK
bigint shop_id FK
string name
string sku
int price_cents
}
ORDER_ITEM {
bigint id PK
bigint order_id FK
bigint product_id FK
int quantity
int unit_price_cents
}
REPORT {
bigint id PK
bigint tenant_id FK
bigint user_id FK
string type
string format
string status
string storage_path
timestamp generated_at
}
ALERT {
bigint id PK
bigint tenant_id FK
string type
string condition
string channel
boolean is_active
}
(2) Key Design Decisions
| Decision | Choice | Reason |
|---|---|---|
| Amount Storage | int price_cents |
Avoid floating-point precision issues |
| Tenant Isolation | tenant_id on every table |
Shared Schema Policy |
| Subscription Status | Stripe Webhook Sync | Single Data Source (Stripe) |
| External ID | external_id UK per tenant |
Different e-commerce platform ID formats |
| Soft Delete | User/Tenant Only | Do not delete orders/products; only change their status |
(1) ▶ Example: ShopMetrics Core Model Definition
// app/Models/Tenant.php
class Tenant extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['name', 'slug', 'domain', 'status'];
protected static function booted(): void
{
static::creating(function (self $tenant) {
$tenant->slug ??= Str::slug($tenant->name);
$tenant->domain ??= "{$tenant->slug}.shopmetrics.io";
});
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function shops(): HasMany
{
return $this->hasMany(Shop::class);
}
public function subscription(): HasOne
{
return $this->hasOne(Subscription::class)->ofMany([], fn ($q) => $q->orderByDesc('created_at'));
}
public function alerts(): HasMany
{
return $this->hasMany(Alert::class);
}
public function isActive(): bool
{
return $this->status === 'active' &&
$this->subscription?->stripe_status === 'active';
}
public function canAddShop(): bool
{
$limit = $this->subscription?->plan->shop_limit ?? 0;
return $this->shops()->count() < $limit;
}
}
// app/Models/Order.php
class Order extends Model
{
use BelongsToTenant, HasFactory;
protected $fillable = [
'tenant_id', 'shop_id', 'external_id',
'customer_email', 'total_cents', 'status', 'ordered_at',
];
protected $casts = [
'total_cents' => 'integer',
'ordered_at' => 'datetime',
];
public function shop(): BelongsTo
{
return $this->belongsTo(Shop::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function getTotalDollarsAttribute(): float
{
return $this->total_cents / 100;
}
}
Output:
// Execution Successful
6. API Design
(1) API Versioning and Resource Planning
| Resource | Prefix | Method | Description |
|---|---|---|---|
| Auth | /api/v1/auth |
POST login/logout/refresh | Authentication |
| Tenants | /api/v1/tenants |
GET/PATCH current | Tenant Information |
| Shops | /api/v1/shops |
CRUD | Store Management |
| Orders | /api/v1/shops/{id}/orders |
GET/POST | Order Inquiry |
| Products | /api/v1/shops/{id}/products |
GET/POST | Product Management |
| Dashboard | /api/v1/dashboard |
GET overview/top/revenue | Dashboard |
| Reports | /api/v1/reports |
POST generate/GET status | Reports |
| Alerts | /api/v1/alerts |
CRUD | Alert Configuration |
| Plans | /api/v1/plans |
GET list | Plan List |
| Subscriptions | /api/v1/subscriptions |
POST/DELETE | Subscription Management |
(2) API Response Specifications
{
"data": {
"id": 1,
"type": "shop",
"attributes": {
"name": "Alice's Amazon Store",
"platform": "amazon",
"status": "active",
"created_at": "2024-01-15T10:00:00Z"
},
"relationships": {
"tenant": { "data": { "id": 1, "type": "tenant" } }
}
},
"meta": {
"request_id": "req_abc123",
"timestamp": "2024-03-15T14:30:00Z"
}
}
(1) ▶ Example: ShopMetrics API Routing Design
// routes/api.php
Route::prefix('v1')->group(function () {
// Public: Authentication
Route::post('auth/login', [AuthController::class, 'login']);
Route::post('auth/register', [AuthController::class, 'register']);
// Authenticated routes
Route::middleware(['auth:sanctum', 'set-tenant-context'])->group(function () {
// Auth
Route::post('auth/logout', [AuthController::class, 'logout']);
Route::get('auth/me', [AuthController::class, 'me']);
// Tenant (current user's tenant)
Route::get('tenant', [TenantController::class, 'show']);
Route::patch('tenant', [TenantController::class, 'update']);
// Shops
Route::apiResource('shops', ShopController::class);
// Nested resources under shops
Route::prefix('shops/{shop}')->group(function () {
Route::apiResource('orders', OrderController::class)->only(['index', 'show']);
Route::apiResource('products', ProductController::class);
});
// Dashboard
Route::prefix('dashboard')->group(function () {
Route::get('overview', [DashboardController::class, 'overview']);
Route::get('top-products', [DashboardController::class, 'topProducts']);
Route::get('revenue', [DashboardController::class, 'revenueChart']);
});
// Reports
Route::apiResource('reports', ReportController::class)->only(['index', 'store', 'show']);
Route::post('reports/{report}/download', [ReportController::class, 'download']);
// Alerts
Route::apiResource('alerts', AlertController::class);
// Subscription
Route::get('plans', [PlanController::class, 'index']);
Route::post('subscriptions', [SubscriptionController::class, 'store']);
Route::get('subscription', [SubscriptionController::class, 'show']);
Route::delete('subscription', [SubscriptionController::class, 'cancel']);
// Team management (tenant_owner only)
Route::middleware('role:tenant_owner')->prefix('team')->group(function () {
Route::get('members', [TeamController::class, 'index']);
Route::post('invite', [TeamController::class, 'invite']);
Route::delete('members/{user}', [TeamController::class, 'remove']);
});
});
// Stripe Webhooks (no auth)
Route::post('webhooks/stripe', [WebhookController::class, 'handleStripe']);
});
Output:
// Execution Successful
7. Technology Selection Decisions
(1) Technology Stack Comparison and Selection
| Level | Option | Selection | Reason |
|---|---|---|---|
| Framework | Laravel/Symfony/Lumen | Laravel 11 | Full-featured, rich ecosystem, rapid SaaS development |
| Database | MySQL/PostgreSQL | MySQL 8.0 | Familiar to the team, default for Laravel, supports JSON |
| Caching | Redis/Memcached | Redis 7 | Unified Caching, Sessions, Queues, and Broadcasting |
| Storage | S3/MinIO/Local | S3 | Scalable, CDN integration, MinIO for development |
| Queue | Redis/Database/SQS | Redis | Low latency, unified development and production environments |
| Authentication | Sanctum/Passport | Sanctum | SPA + Mobile Token Authentication is Sufficient |
| Real-time | Pusher/Soketi | Soketi | Compatible with the Pusher protocol; self-hosted with no fees |
| Front-end | Blade/Inertia/Livewire | Inertia + Vue | SPA Experience + Server-Side Routing |
(2) Architecture Overview
flowchart TB
subgraph Client["Client Layer"]
WEB[Web SPA - Inertia/Vue]
MOBILE[Mobile App]
API_CLIENT[API Consumers]
end
subgraph LB["Load Balancer - Nginx"]
direction LR
direction TB
end
subgraph App["Application Layer"]
API[API Server - Laravel]
WS[WebSocket - Soketi]
end
subgraph Worker["Background Processing"]
QUEUE[Queue Workers]
SCHED[Scheduler]
end
subgraph Data["Data Layer"]
DB[(MySQL 8.0)]
CACHE[(Redis 7)]
S3[S3/MinIO]
end
subgraph External["External Services"]
STRIPE[Stripe API]
SHOPS[E-commerce APIs]
MAIL[Mail Service]
end
WEB --> LB
MOBILE --> LB
API_CLIENT --> LB
LB --> API
WEB --> WS
API --> DB
API --> CACHE
API --> S3
API --> STRIPE
API --> SHOPS
API --> QUEUE
API --> WS
QUEUE --> DB
QUEUE --> CACHE
QUEUE --> S3
QUEUE --> MAIL
QUEUE --> STRIPE
SCHED --> QUEUE
(1) ▶ Example: ShopMetrics Technology Selection Configuration Profile
// config/shopmetrics.php
return [
'tenant' => [
'strategy' => env('TENANT_STRATEGY', 'shared_schema'),
'default_plan' => env('DEFAULT_PLAN_SLUG', 'starter'),
'trial_days' => env('TRIAL_DAYS', 14),
],
'limits' => [
'starter' => ['shops' => 3, 'users' => 5, 'reports_per_month' => 10],
'pro' => ['shops' => 25, 'users' => 25, 'reports_per_month' => 100],
'enterprise' => ['shops' => -1, 'users' => -1, 'reports_per_month' => -1],
],
'reports' => [
'max_date_range_days' => 365,
'formats' => ['csv', 'xlsx', 'json'],
'storage_disk' => env('REPORT_DISK', 's3'),
'retention_days' => env('REPORT_RETENTION_DAYS', 90),
],
'sync' => [
'platforms' => ['amazon', 'shopify', 'ebay', 'etsy'],
'sync_interval_minutes' => env('SYNC_INTERVAL', 60),
'batch_size' => env('SYNC_BATCH_SIZE', 500),
],
'alerts' => [
'channels' => ['email', 'slack', 'webhook'],
'check_interval_minutes' => env('ALERT_CHECK_INTERVAL', 15),
],
];
Output:
// Execution Successful
8. Comprehensive Example: Initializing the ShopMetrics Project
// ============================================
// Comprehensive: Project scaffold and initial setup
// Database migrations, models, and config
// ============================================
// database/migrations/2024_01_01_000001_create_tenants_table.php
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('domain')->unique();
$table->string('status')->default('active'); // active/suspended/cancelled
$table->softDeletes();
$table->timestamps();
$table->index('status');
});
// database/migrations/2024_01_01_000002_create_plans_table.php
Schema::create('plans', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->integer('shop_limit')->default(3);
$table->integer('user_limit')->default(5);
$table->integer('price_cents')->default(0);
$table->string('stripe_price_id')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
// database/migrations/2024_01_01_000003_create_subscriptions_table.php
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('plan_id')->constrained();
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'stripe_status']);
});
// database/migrations/2024_01_01_000004_create_shops_table.php
Schema::create('shops', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('platform'); // amazon/shopify/ebay/etsy
$table->string('external_id');
$table->string('status')->default('active');
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'platform', 'external_id']);
$table->index(['tenant_id', 'status']);
});
// database/migrations/2024_01_01_000005_create_orders_table.php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('shop_id')->constrained()->cascadeOnDelete();
$table->string('external_id');
$table->string('customer_email')->nullable();
$table->unsignedBigInteger('total_cents')->default(0);
$table->string('status')->default('pending');
$table->timestamp('ordered_at');
$table->timestamps();
$table->unique(['tenant_id', 'external_id']);
$table->index(['tenant_id', 'shop_id', 'status', 'ordered_at']);
$table->index(['tenant_id', 'ordered_at']);
});
❓ FAQ
tenant_id; the API layer validates the tenant to which the current user belongs; and raw SQL is prevented from bypassing Scope. For higher isolation (e.g., financial or healthcare), use a separate database strategy.Alert may belong to a Shop or a Tenant, so polymorphic associations can be considered. However, if the association type is fixed, using a foreign key provides greater clarity./api/v1/). When v1 and v2 coexist, v2 routes are handled in a separate file, while the Model and Service layers are shared. A new version number is assigned only for major version upgrades; minor changes are implemented in a backward-compatible manner.tenant_id column and create a data migration script. When migrating from a single-tenant to a multi-tenant environment, use an Artisan command to assign tenant_id values in bulk; after processing is complete, add a NOT NULL constraint.📖 Summary
- User stories are broken down into functional modules based on three types of characters (Alice, Bob, and Charlie)
- A shared database and shared schema are the best choice for most SaaS applications
- Core ER Design: tenant_id isolation, amounts in cents, unique constraint on external ID
- API design follows the RESTful architecture, uses version prefixes, and employs a unified response format
- When selecting technologies, priority is given to the team's capabilities and the maturity of the ecosystem
- Project initialization begins with the migration file, laying the foundation for the data model
📝 Exercises
-
Basic Exercise (⭐): Based on the ER diagram, write the schema files for the remaining three tables in ShopMetrics (products, order_items, alerts), including the necessary indexes and foreign keys.
-
Advanced Exercise (⭐⭐): Using the OpenAPI 3.0 YAML format, write the documentation for ShopMetrics' Orders API (GET list / GET detail / POST sync), including request parameters, response formats, and error codes.
-
Challenge (⭐⭐⭐): Design a complete multi-tenant middleware solution for ShopMetrics—implement the
SetTenantContextmiddleware, theBelongsToTenanttrait, and use path parameters (subdomain) to identify tenants. Write tests to verify that Tenant A cannot access Tenant B's data.



