404 Not Found

404 Not Found


nginx

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


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.

TEXT
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

TEXT
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

PHP
// 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:

TEXT
// 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

100%
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:

(1) ▶ Example: Multi-tenant Global Scope Implementation

PHP
// 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:

TEXT
// Execution Successful

5. Database ER Design

(1) Core Entity Relationships

100%
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

PHP
// 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:

TEXT
// 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

JSON
{
    "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

PHP
// 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:

TEXT
// 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

100%
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

PHP
// 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:

TEXT
// Execution Successful

8. Comprehensive Example: Initializing the ShopMetrics Project

PHP
// ============================================
// 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

Q Is tenant isolation with a shared schema secure enough?
A It is sufficient for most SaaS applications. Key measures: Global Scope automatically filters 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.
Q When should polymorphic associations be used in ER design?
A Polymorphic associations are suitable for scenarios where "a single entity can belong to multiple parent entities" (e.g., a Comment can belong to a Post or a Video). In ShopMetrics, an 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.
Q How should API versions be managed?
A The simplest approach is to use a URL prefix for versioning (/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.
Q Should team capabilities be considered when selecting technologies?
A Absolutely. Even the most advanced tech stack can actually slow things down if the team isn't familiar with it. Prioritize the technologies the team is most familiar with, followed by the maturity of the ecosystem, and lastly, technological advancement.
Q How detailed should the design phase be?
A Core entities and relationships must be 100% finalized (ER diagram, API endpoints); implementation details can be adjusted during coding. Design goal: There should be no need to "invent" new tables or interfaces during coding.
Q How do I handle data migration in a multi-tenant environment?
A It's simple for shared schemas—just add a 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


📝 Exercises

  1. 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.

  2. 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.

  3. Challenge (⭐⭐⭐): Design a complete multi-tenant middleware solution for ShopMetrics—implement the SetTenantContext middleware, the BelongsToTenant trait, and use path parameters (subdomain) to identify tenants. Write tests to verify that Tenant A cannot access Tenant B's data.

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏