Project Development — Coding Implementation of the ShopMetrics SaaS Platform
The design diagrams have finally been turned into code—but don't rush. Implement them one module at a time, and test each module as you finish it.
1. What You'll Learn
- Multi-tenant middleware and the implementation of scope isolation
- Stripe Subscription Billing Integration: Plan/Subscription/Webhook Processing
- Data Analysis Dashboard: Eloquent Aggregates + Chart.js Visualization
- Asynchronous report generation: Queue Job + S3 storage + email notifications
- Full implementation of the API layer: authentication, resources, filtering, pagination, and version control
2. A True Story of Collaborative Development
(1) Pain Point: Three people are each writing their own code, which leads to conflicts when merging the code.
Bob assigned tasks: Alice was to write the tenant management code, and Charlie was to write the data analysis code. Three days later, when they merged the code, the relationships between Alice's Tenant model and Charlie's Order model didn't align, and their middleware logic conflicted with each other, resulting in 500 errors across the entire site after the merge. Bob said, "We need a development process that ensures everyone's code can run independently."
(2) Solutions for Modular Development
Develop in the following order of priority: Authentication → Tenant → Store → Orders → Analytics → Billing. After completing each module, run tests and merge the code to ensure the main branch is always in a runnable state.
Week 1: Auth + Tenant + Middleware (Basic Framework)
Week 2: Shop + Order + Product (Core Business)
Week 3: Dashboard + Analytics (Data Analysis)
Week 4: Subscription + Stripe (Billing System)
(3) Revenue
After adopting modular development, the three developers merged their code once a day, reducing the conflict rate from 40% to 5%, and the main branch remained consistently runnable.
3. Multi-tenant Middleware and Scope
(1) Request Processing Workflow
sequenceDiagram
participant Client
participant Nginx
participant Middleware
participant TenantScope
participant Controller
participant DB
Client->>Nginx: GET alice.shopmetrics.io/api/v1/shops
Nginx->>Middleware: X-Tenant: alice
Middleware->>DB: SELECT * FROM tenants WHERE slug=alice
DB-->>Middleware: Tenant{id:1, slug:alice}
Middleware->>TenantScope: Set current tenant
Middleware->>Controller: Request with tenant context
Controller->>DB: SELECT * FROM shops WHERE tenant_id=1
DB-->>Controller: Alice's shops only
Controller-->>Client: 200 OK {data: [...]}
(2) Middleware Implementation
// app/Http/Middleware/SetTenantContext.php
class SetTenantContext
{
public function handle(Request $request, Closure $next): Response
{
if (!$user = $request->user()) {
return $next($request);
}
$tenant = $user->tenant;
if (!$tenant || !$tenant->isActive()) {
abort(403, 'Tenant account is inactive or suspended.');
}
// Check plan limits
if ($tenant->isOverLimit()) {
abort(402, 'Plan limit exceeded. Please upgrade your subscription.');
}
Tenant::setCurrent($tenant);
$request->attributes->set('tenant', $tenant);
return $next($request);
}
}
// app/Http/Kernel.php - Register middleware
protected $middlewareAliases = [
'tenant' => SetTenantContext::class,
];
(1) ▶ Example: Complete Implementation of the BelongsToTenant Trait
// app/Models/Traits/BelongsToTenant.php
namespace App\Models\Traits;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $builder) {
$tenant = Tenant::current();
if ($tenant) {
$builder->where($builder->getModel()->getTable() . '.tenant_id', $tenant->id);
}
});
static::creating(function (Model $model) {
$tenant = Tenant::current();
if ($tenant && !$model->isDirty('tenant_id')) {
$model->tenant_id = $tenant->id;
}
});
static::saving(function (Model $model) {
$tenant = Tenant::current();
if ($tenant) {
$currentTenantId = $model->getOriginal('tenant_id') ?? $model->tenant_id;
if ((int) $currentTenantId !== $tenant->id) {
throw new \Illuminate\Auth\Access\AuthorizationException(
'Cannot modify data belonging to another tenant.'
);
}
}
});
}
public function tenant(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function scopeForTenant(Builder $query, Tenant $tenant): Builder
{
return $query->withoutGlobalScope('tenant')
->where('tenant_id', $tenant->id);
}
public function scopeAllTenants(Builder $query): Builder
{
return $query->withoutGlobalScope('tenant');
}
}
Output:
// Execution Successful
4. Stripe Subscription Billing Integration
(1) Subscription Lifecycle
stateDiagram-v2
[*] --> Trial: New Signup
Trial --> Active: Trial→Paid
Trial --> Cancelled: Cancel during trial
Active --> PastDue: Payment failed
PastDue --> Active: Payment retry success
PastDue --> Cancelled: Retry exhausted
Active --> Cancelled: User cancels
Cancelled --> Active: Resubscribe
(2) The Plan and Subscription Models
// app/Models/Plan.php
class Plan extends Model
{
protected $fillable = [
'name', 'slug', 'shop_limit', 'user_limit',
'price_cents', 'stripe_price_id', 'is_active',
];
protected $casts = [
'price_cents' => 'integer',
'shop_limit' => 'integer',
'user_limit' => 'integer',
'is_active' => 'boolean',
];
public function subscriptions(): HasMany
{
return $this->hasMany(Subscription::class);
}
public function isFree(): bool
{
return $this->price_cents === 0;
}
public function formattedPrice(): string
{
return $this->isFree() ? 'Free' : '$' . number_format($this->price_cents / 100, 2) . '/mo';
}
}
// app/Models/Subscription.php
class Subscription extends Model
{
protected $fillable = [
'tenant_id', 'plan_id', 'stripe_id',
'stripe_status', 'trial_ends_at', 'ends_at',
];
protected $casts = [
'trial_ends_at' => 'datetime',
'ends_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function plan(): BelongsTo
{
return $this->belongsTo(Plan::class);
}
public function isActive(): bool
{
return in_array($this->stripe_status, ['active', 'trialing']);
}
public function onTrial(): bool
{
return $this->stripe_status === 'trialing' ||
($this->trial_ends_at && $this->trial_ends_at->isFuture());
}
public function onGracePeriod(): bool
{
return $this->ends_at && $this->ends_at->isFuture();
}
}
(1) ▶ Example: Stripe Checkout and Webhook Processing
// app/Http/Controllers/SubscriptionController.php
class SubscriptionController extends Controller
{
public function store(Request $request)
{
$request->validate(['plan_slug' => 'required|exists:plans,slug']);
$plan = Plan::whereSlug($request->plan_slug)->firstOrFail();
$tenant = $request->attributes->get('tenant');
if ($plan->isFree()) {
$tenant->updateToFreePlan($plan);
return response()->json(['message' => 'Subscribed to free plan']);
}
// Create Stripe Checkout session
$checkout = Stripe::checkout()->sessions()->create([
'customer' => $tenant->stripe_customer_id ?? $this->createStripeCustomer($tenant),
'mode' => 'subscription',
'line_items' => [[
'price' => $plan->stripe_price_id,
'quantity' => 1,
]],
'subscription_data' => [
'trial_period_days' => config('shopmetrics.tenant.trial_days'),
'metadata' => ['tenant_id' => $tenant->id],
],
'success_url' => config('app.url') . '/billing?success=1',
'cancel_url' => config('app.url') . '/billing?cancel=1',
]);
return response()->json(['checkout_url' => $checkout->url]);
}
public function cancel(Request $request)
{
$tenant = $request->attributes->get('tenant');
$subscription = $tenant->subscription;
Stripe::subscriptions()->update($subscription->stripe_id, [
'cancel_at_period_end' => true,
]);
$subscription->update(['ends_at' => $subscription->current_period_end]);
return response()->json(['message' => 'Subscription will cancel at period end']);
}
}
// app/Http/Controllers/WebhookController.php
class WebhookController extends Controller
{
public function handleStripe(Request $request)
{
$payload = $request->all();
$event = Stripe::webhooks()->constructEvent(
$payload,
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret')
);
match ($event->type) {
'customer.subscription.created' => $this->handleSubscriptionCreated($event),
'customer.subscription.updated' => $this->handleSubscriptionUpdated($event),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event),
'invoice.payment_failed' => $this->handlePaymentFailed($event),
default => null,
};
return response()->json(['received' => true]);
}
private function handleSubscriptionUpdated($event): void
{
$stripeSubscription = $event->data->object;
$subscription = Subscription::whereStripeId($stripeSubscription->id)->firstOrFail();
$subscription->update([
'stripe_status' => $stripeSubscription->status,
'trial_ends_at' => $stripeSubscription->trial_end
? \Carbon\Carbon::createFromTimestamp($stripeSubscription->trial_end)
: null,
'ends_at' => $stripeSubscription->cancel_at_period_end
? \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_end)
: null,
]);
}
private function handlePaymentFailed($event): void
{
$stripeSubscription = $event->data->object->subscription;
$subscription = Subscription::whereStripeId($stripeSubscription)->first();
if ($subscription) {
$subscription->tenant->users->each(
fn ($user) => $user->notify(new PaymentFailedNotification())
);
}
}
}
Output:
// Execution Successful
5. Data Analytics Dashboard
(1) Dashboard API Design
| Endpoint | Returned Data | Cache TTL |
|---|---|---|
GET /dashboard/overview |
Total Revenue/Number of Orders/Number of Customers/Year-over-Year | 15 minutes |
GET /dashboard/revenue |
Revenue Trend Chart Data | 30 minutes |
GET /dashboard/top-products |
Top 10 Products | 1 hour |
GET /dashboard/shop-comparison |
Store Comparison | 1 hour |
(1) ▶ Example: ShopMetrics Dashboard Service
// app/Services/DashboardService.php
class DashboardService
{
public function getOverview(Tenant $tenant): array
{
return Cache::remember(
"dashboard:{$tenant->id}:overview",
now()->addMinutes(15),
fn () => $this->calculateOverview($tenant)
);
}
private function calculateOverview(Tenant $tenant): array
{
$currentMonth = now()->startOfMonth();
$lastMonth = now()->subMonth()->startOfMonth();
$current = DB::table('orders')
->where('tenant_id', $tenant->id)
->where('ordered_at', '>=', $currentMonth)
->select([
DB::raw('SUM(total_cents) as revenue_cents'),
DB::raw('COUNT(*) as order_count'),
DB::raw('COUNT(DISTINCT customer_email) as customer_count'),
])
->first();
$previous = DB::table('orders')
->where('tenant_id', $tenant->id)
->whereBetween('ordered_at', [$lastMonth, $currentMonth])
->select([
DB::raw('SUM(total_cents) as revenue_cents'),
DB::raw('COUNT(*) as order_count'),
])
->first();
return [
'revenue' => [
'current' => (float) ($current->revenue_cents / 100),
'previous' => (float) ($previous->revenue_cents / 100),
'change_percent' => $this->percentChange(
$previous->revenue_cents, $current->revenue_cents
),
],
'orders' => [
'current' => (int) $current->order_count,
'previous' => (int) $previous->order_count,
'change_percent' => $this->percentChange(
$previous->order_count, $current->order_count
),
],
'customers' => (int) $current->customer_count,
];
}
public function getRevenueChart(Tenant $tenant, string $period = '30d'): array
{
$days = match ($period) {
'7d' => 7, '30d' => 30, '90d' => 90, '1y' => 365,
default => 30,
};
return Cache::remember(
"dashboard:{$tenant->id}:revenue:{$period}",
now()->addMinutes(30),
fn () => $this->calculateRevenueChart($tenant, $days)
);
}
private function calculateRevenueChart(Tenant $tenant, int $days): array
{
$data = DB::table('orders')
->where('tenant_id', $tenant->id)
->where('ordered_at', '>=', now()->subDays($days))
->groupBy('date')
->orderBy('date')
->select([
DB::raw('DATE(ordered_at) as date'),
DB::raw('SUM(total_cents) as revenue_cents'),
DB::raw('COUNT(*) as order_count'),
])
->get();
return [
'labels' => $data->pluck('date')->map(fn ($d) => \Carbon\Carbon::parse($d)->format('M d')),
'revenue' => $data->pluck('revenue_cents')->map(fn ($v) => $v / 100),
'orders' => $data->pluck('order_count'),
];
}
private function percentChange(float|int $old, float|int $new): float
{
if ($old == 0) return $new > 0 ? 100.0 : 0.0;
return round((($new - $old) / $old) * 100, 1);
}
}
Output:
// Execution Successful
6. Asynchronous Report Generation
(1) Report Generation Process
sequenceDiagram
participant User as Alice
participant API as API Server
participant Queue as Redis Queue
participant Worker as Queue Worker
participant S3 as S3 Storage
participant Mail as Email Service
User->>API: POST /reports {type: monthly, format: xlsx}
API->>Queue: Dispatch GenerateReportJob
API-->>User: 202 {report_id, status: pending}
Queue->>Worker: Pick up job
Worker->>Worker: Query & aggregate data
Worker->>S3: Upload report file
Worker->>Worker: Update report status=completed
Worker->>Mail: Send notification email
Mail-->>User: Report ready email
User->>API: GET /reports/{id}
API-->>User: {status: completed, download_url}
(1) ▶ Example: ShopMetrics Report Generation Job
// app/Jobs/GenerateReportJob.php
class GenerateReportJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public int $timeout = 600;
public function __construct(
public Tenant $tenant,
public string $type,
public string $format,
public array $filters = [],
) {}
public function handle(
ReportGenerator $generator,
ReportStorage $storage,
): void {
$report = Report::create([
'tenant_id' => $this->tenant->id,
'type' => $this->type,
'format' => $this->format,
'status' => 'processing',
'filters' => $this->filters,
]);
try {
$data = $this->fetchData();
$filePath = $generator->generate($data, $this->format, $report);
$storagePath = $storage->store($filePath, $this->tenant, $report);
$report->update([
'status' => 'completed',
'storage_path' => $storagePath,
'generated_at' => now(),
]);
$this->notifyUser($report);
} catch (\Throwable $e) {
$report->update(['status' => 'failed']);
throw $e;
} finally {
Cache::forget("dashboard:{$this->tenant->id}:*");
}
}
private function fetchData(): Collection
{
return match ($this->type) {
'monthly' => $this->fetchMonthlyData(),
'product_comparison' => $this->fetchProductComparison(),
'shop_performance' => $this->fetchShopPerformance(),
default => throw new \InvalidArgumentException("Unknown report type: {$this->type}"),
};
}
private function fetchMonthlyData(): Collection
{
return Order::with('items.product')
->where('tenant_id', $this->tenant->id)
->whereBetween('ordered_at', [
$this->filters['date_from'] ?? now()->subMonth(),
$this->filters['date_to'] ?? now(),
])
->orderBy('ordered_at')
->get();
}
private function notifyUser(Report $report): void
{
$user = User::find($this->filters['user_id'] ?? $this->tenant->users()->first()->id);
$user?->notify(new ReportReadyNotification($report));
}
}
// app/Services/ReportGenerator.php
class ReportGenerator
{
public function generate(Collection $data, string $format, Report $report): string
{
return match ($format) {
'csv' => $this->generateCsv($data, $report),
'xlsx' => $this->generateExcel($data, $report),
'json' => $this->generateJson($data, $report),
default => throw new \InvalidArgumentException("Unsupported format: {$format}"),
};
}
private function generateCsv(Collection $data, Report $report): string
{
$path = tempnam(sys_get_temp_dir(), 'report_');
$file = fopen($path, 'w');
fputcsv($file, ['Order ID', 'Date', 'Customer', 'Total', 'Status']);
foreach ($data as $order) {
fputcsv($file, [
$order->external_id,
$order->ordered_at->format('Y-m-d'),
$order->customer_email,
number_format($order->total_cents / 100, 2),
$order->status,
]);
}
fclose($file);
return $path;
}
}
Output:
// Execution Successful
7. Full Implementation of the API Layer
(1) API Resource Output
// app/Http/Resources/ShopResource.php
class ShopResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'platform' => $this->platform,
'status' => $this->status,
'metadata' => $this->metadata,
'order_count' => $this->whenCounted('orders'),
'revenue_cents' => $this->whenAggregated('orders', 'total_cents', 'sum'),
'created_at' => $this->created_at->toIso8601String(),
];
}
}
// app/Http/Resources/OrderResource.php
class OrderResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'external_id' => $this->external_id,
'customer_email' => $this->customer_email,
'total' => [
'cents' => $this->total_cents,
'formatted' => '$' . number_format($this->total_cents / 100, 2),
],
'status' => $this->status,
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'ordered_at' => $this->ordered_at->toIso8601String(),
];
}
}
(2) Filtering and Pagination
// app/Http/Controllers/OrderController.php
class OrderController extends Controller
{
public function index(Request $request)
{
$query = Order::with(['items.product'])
->where('shop_id', $request->route('shop')->id);
// Filters
if ($status = $request->query('status')) {
$query->where('status', $status);
}
if ($dateFrom = $request->query('date_from')) {
$query->where('ordered_at', '>=', $dateFrom);
}
if ($dateTo = $request->query('date_to')) {
$query->where('ordered_at', '<=', $dateTo);
}
if ($minTotal = $request->query('min_total')) {
$query->where('total_cents', '>=', $minTotal * 100);
}
if ($search = $request->query('search')) {
$query->where('external_id', 'like', "%{$search}%")
->orWhere('customer_email', 'like', "%{$search}%");
}
// Sort
$sortField = $request->query('sort_by', 'ordered_at');
$sortDir = $request->query('sort_dir', 'desc');
$query->orderBy($sortField, $sortDir);
// Paginate
$orders = $query->paginate($request->query('per_page', 25));
return OrderResource::collection($orders);
}
}
(1) ▶ Example: ShopMetrics Complete API Routes and Controllers
// routes/api.php
Route::prefix('v1')->group(function () {
Route::post('auth/login', [AuthController::class, 'login']);
Route::post('auth/register', [AuthController::class, 'register']);
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
Route::post('auth/logout', [AuthController::class, 'logout']);
Route::get('auth/me', [AuthController::class, 'me']);
Route::apiResource('shops', ShopController::class);
Route::prefix('shops/{shop}')->group(function () {
Route::apiResource('orders', OrderController::class)->only(['index', 'show']);
Route::apiResource('products', ProductController::class);
});
Route::prefix('dashboard')->group(function () {
Route::get('overview', [DashboardController::class, 'overview']);
Route::get('revenue', [DashboardController::class, 'revenue']);
Route::get('top-products', [DashboardController::class, 'topProducts']);
});
Route::apiResource('reports', ReportController::class)->only(['index', 'store', 'show']);
Route::get('reports/{report}/download', [ReportController::class, 'download']);
Route::apiResource('alerts', AlertController::class);
Route::get('plans', [PlanController::class, 'index']);
Route::post('subscriptions', [SubscriptionController::class, 'store']);
Route::get('subscription', [SubscriptionController::class, 'show']);
Route::delete('subscription', [SubscriptionController::class, 'cancel']);
Route::middleware('role:tenant_owner')->prefix('team')->group(function () {
Route::apiResource('members', TeamController::class)->only(['index', 'destroy']);
Route::post('invite', [TeamController::class, 'invite']);
});
});
Route::post('webhooks/stripe', [WebhookController::class, 'handleStripe']);
});
Output:
// Execution Successful
8. Comprehensive Example: ShopMetrics Full Feature Integration
// ============================================
// Comprehensive: End-to-end request flow
// From login → dashboard → report → notification
// ============================================
// Step 1: Alice logs in
// POST /api/v1/auth/login {email, password}
class AuthController extends Controller
{
public function login(Request $request)
{
$credentials = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
if (!Auth::attempt($credentials)) {
return response()->json(['message' => 'Invalid credentials'], 401);
}
$user = Auth::user();
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json([
'user' => new UserResource($user),
'token' => $token,
'tenant' => new TenantResource($user->tenant),
]);
}
}
// Step 2: Alice views dashboard (tenant middleware auto-sets context)
// GET /api/v1/dashboard/overview
class DashboardController extends Controller
{
public function overview(Request $request)
{
$tenant = $request->attributes->get('tenant');
return response()->json(
app(DashboardService::class)->getOverview($tenant)
);
}
}
// Step 3: Alice generates a monthly report
// POST /api/v1/reports {type: monthly, format: xlsx, date_from, date_to}
class ReportController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'type' => 'required|in:monthly,product_comparison,shop_performance',
'format' => 'required|in:csv,xlsx,json',
'date_from' => 'nullable|date',
'date_to' => 'nullable|date|after:date_from',
]);
$tenant = $request->attributes->get('tenant');
// Check plan limits
$monthlyReports = Report::where('tenant_id', $tenant->id)
->whereMonth('created_at', now()->month)
->count();
$limit = $tenant->subscription->plan->limits['reports_per_month'] ?? 10;
if ($monthlyReports >= $limit && $limit !== -1) {
return response()->json([
'message' => 'Monthly report limit reached. Upgrade your plan.',
], 402);
}
GenerateReportJob::dispatch(
$tenant,
$validated['type'],
$validated['format'],
[...$validated, 'user_id' => $request->user()->id]
);
return response()->json([
'message' => 'Report generation started',
'estimated_time' => '2-5 minutes',
], 202);
}
}
// Step 4: Worker processes the job, uploads to S3, sends email
// (See GenerateReportJob in Section 6)
// Step 5: Alice downloads the report
// GET /api/v1/reports/{report}/download
class ReportController extends Controller
{
public function download(Report $report, Request $request)
{
$tenant = $request->attributes->get('tenant');
if ($report->tenant_id !== $tenant->id) {
abort(403);
}
if ($report->status !== 'completed') {
return response()->json(['message' => 'Report not ready', 'status' => $report->status], 202);
}
// Generate temporary S3 URL (5 minutes)
$url = Storage::disk('s3')->temporaryUrl(
$report->storage_path,
now()->addMinutes(5)
);
return redirect($url);
}
}
❓ FAQ
withoutGlobalScope(). Mitigation measures: Verify tenant_id ownership at the API layer, implement Row-Level Security (RLS) policies, and log bypass attempts in audit logs.dashboard:{tenant_id}:overview. When checking for expiry, use the prefix to scan the cache (Redis SCAN); do not use KEYS (which will cause a block).whenLoaded be used for an API resource??include=items.product, and the controller determines with() based on the include parameter.📖 Summary
- Use Global Scope + Middleware to automatically inject the
tenant_idfor multi-tenant isolation - Stripe Checkout handles the payment process, and Webhooks synchronize subscription status
- The dashboard's Service layer encapsulates aggregation logic, and Redis caching reduces the load on the database
- Reports are generated asynchronously using a Queue Job, stored in S3, and users are notified via email
- Standardized output format for API resources; filtering, pagination, and sorting are encapsulated in the controller
- Modular development: Authentication → Tenant → Store → Order → Analytics → Billing; proceed in this order
📝 Exercises
-
Basic Exercise (⭐): Implement ShopResource and OrderResource, including associated data (whenLoaded), and write the corresponding feature tests to verify the JSON structure.
-
Advanced Exercise (⭐⭐): Fully implement the Stripe subscription workflow—creating a Checkout Session + handling webhooks (3 events: created/updated/deleted) + synchronizing subscription status + sending email notifications for payment failures.
-
Challenge (⭐⭐⭐): Implement a complete report generation system—GenerateReportJob—that supports both CSV and JSON formats, progress tracking (storing progress percentages in Redis), temporary S3 download URLs (valid for 5 minutes), scheduled job limit checks, and email notifications upon completion. Write feature tests to cover the entire workflow: Create → In Progress → Complete → Download.



