Building SaaS with Laravel (Part 6): Admin Dashboard & Metrics
·
17 min read
In Part 5, we built usage tracking. Now it's time for the part that you — as a SaaS owner — need most: an admin dashboard to monitor the entire system.
1. Admin Architecture
Admin operates outside the tenant context — you need to see all tenants, all data.
┌──────────────────────────────────┐
│ Admin Dashboard │
│ │
│ ┌───────────┐ ┌──────────────┐ │
│ │ Revenue │ │ Tenants │ │
│ │ Metrics │ │ Overview │ │
│ └───────────┘ └──────────────┘ │
│ ┌───────────┐ ┌──────────────┐ │
│ │ Growth │ │ Health │ │
│ │ Charts │ │ Checks │ │
│ └───────────┘ └──────────────┘ │
└──────────────────────────────────┘
2. Admin Middleware
// app/Http/Middleware/AdminOnly.php
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AdminOnly
{
/**
* Admin is determined by email in config.
* Simple approach — no extra complexity for a personal SaaS.
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user || ! in_array($user->email, config('admin.emails', []), true)) {
abort(403, 'Unauthorized.');
}
return $next($request);
}
}
// config/admin.php
<?php
return [
'emails' => explode(',', env('ADMIN_EMAILS', '')),
];
ADMIN_EMAILS=you@example.com,co-founder@example.com
3. Metrics Service
The central service for calculating all metrics:
// app/Domain/Admin/Services/MetricsService.php
<?php
declare(strict_types=1);
namespace App\Domain\Admin\Services;
use App\Domain\Tenant\Models\Tenant;
use App\Domain\User\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Laravel\Cashier\Subscription;
class MetricsService
{
/**
* Main overview metrics.
*/
public function overview(): array
{
return Cache::remember('admin:metrics:overview', 300, function () {
return [
'total_tenants' => Tenant::count(),
'active_tenants' => Tenant::where('status', 'active')->count(),
'trial_tenants' => Tenant::whereNotNull('trial_ends_at')
->where('trial_ends_at', '>', now())
->whereDoesntHave('subscriptions')
->count(),
'total_users' => User::count(),
'mrr' => $this->calculateMRR(),
'arr' => $this->calculateMRR() * 12,
'churn_rate' => $this->calculateChurnRate(),
'trial_conversion_rate' => $this->trialConversionRate(),
];
});
}
/**
* Monthly Recurring Revenue — the most important metric.
*/
public function calculateMRR(): float
{
return Cache::remember('admin:metrics:mrr', 600, function () {
$subscriptions = Subscription::where('stripe_status', 'active')->get();
$mrr = 0;
foreach ($subscriptions as $subscription) {
$tenant = Tenant::find($subscription->billable_id);
if (! $tenant?->plan) {
continue;
}
$plan = $tenant->plan;
if ($subscription->stripe_price === $plan->stripe_yearly_price_id) {
// Yearly subscription → MRR = yearly / 12
$mrr += $plan->yearly_price / 12;
} else {
// Monthly subscription
$mrr += $plan->monthly_price;
}
}
return round($mrr, 2);
});
}
/**
* MRR over time (for charts).
*/
public function mrrOverTime(int $months = 12): array
{
$data = [];
for ($i = $months - 1; $i >= 0; $i--) {
$date = now()->subMonths($i);
$monthKey = $date->format('Y-m');
// Count active subscriptions at the end of each month
$activeCount = Subscription::where('stripe_status', 'active')
->where('created_at', '<=', $date->endOfMonth())
->where(function ($q) use ($date) {
$q->whereNull('ends_at')
->orWhere('ends_at', '>', $date->endOfMonth());
})
->count();
$data[] = [
'month' => $monthKey,
'label' => $date->format('M Y'),
'subscriptions' => $activeCount,
];
}
return $data;
}
/**
* Churn rate: % of tenants who cancelled in the current month.
*/
public function calculateChurnRate(): float
{
$startOfMonth = now()->startOfMonth();
$activeStart = Subscription::where('stripe_status', 'active')
->where('created_at', '<', $startOfMonth)
->count();
if ($activeStart === 0) {
return 0;
}
$cancelled = Subscription::where('stripe_status', 'canceled')
->where('updated_at', '>=', $startOfMonth)
->count();
return round(($cancelled / $activeStart) * 100, 2);
}
/**
* Conversion rate from trial to paid.
*/
public function trialConversionRate(): float
{
$totalTrials = Tenant::whereNotNull('trial_ends_at')
->where('trial_ends_at', '<', now()) // Trial has ended
->count();
if ($totalTrials === 0) {
return 0;
}
$converted = Tenant::whereNotNull('trial_ends_at')
->where('trial_ends_at', '<', now())
->whereHas('subscriptions', function ($q) {
$q->where('stripe_status', 'active');
})
->count();
return round(($converted / $totalTrials) * 100, 2);
}
/**
* Top tenants by user count.
*/
public function topTenants(int $limit = 10): array
{
return Tenant::withCount('users')
->with('plan')
->orderByDesc('users_count')
->limit($limit)
->get()
->map(fn (Tenant $t) => [
'id' => $t->id,
'name' => $t->name,
'slug' => $t->slug,
'plan' => $t->plan?->name ?? 'No plan',
'users_count' => $t->users_count,
'status' => $t->status,
'created_at' => $t->created_at->format('Y-m-d'),
])
->toArray();
}
/**
* Signups by day (last 30 days).
*/
public function signupsOverTime(int $days = 30): array
{
return Tenant::where('created_at', '>=', now()->subDays($days))
->select(
DB::raw('DATE(created_at) as date'),
DB::raw('COUNT(*) as count')
)
->groupBy('date')
->orderBy('date')
->get()
->map(fn ($row) => [
'date' => $row->date,
'count' => $row->count,
])
->toArray();
}
/**
* Revenue breakdown by plan.
*/
public function revenueByPlan(): array
{
return Tenant::whereHas('subscriptions', function ($q) {
$q->where('stripe_status', 'active');
})
->with('plan')
->get()
->groupBy(fn (Tenant $t) => $t->plan?->slug ?? 'unknown')
->map(function ($tenants, $planSlug) {
$plan = $tenants->first()->plan;
return [
'plan' => $plan?->name ?? 'Unknown',
'count' => $tenants->count(),
'mrr' => $tenants->sum(fn ($t) => $t->plan?->monthly_price ?? 0),
];
})
->values()
->toArray();
}
}
4. Admin Controller
// app/Http/Controllers/Admin/AdminDashboardController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Domain\Admin\Services\MetricsService;
use App\Http\Controllers\Controller;
class AdminDashboardController extends Controller
{
public function __construct(
private readonly MetricsService $metrics,
) {}
public function index()
{
return view('admin.dashboard', [
'overview' => $this->metrics->overview(),
'mrrChart' => $this->metrics->mrrOverTime(),
'signupsChart' => $this->metrics->signupsOverTime(),
'revenueByPlan' => $this->metrics->revenueByPlan(),
'topTenants' => $this->metrics->topTenants(),
]);
}
}
5. Tenant Management Controller
// app/Http/Controllers/Admin/TenantManagementController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Domain\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class TenantManagementController extends Controller
{
public function index(Request $request)
{
$query = Tenant::with(['plan', 'owner'])
->withCount('users');
// Search
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('slug', 'like', "%{$search}%")
->orWhereHas('owner', fn ($q) => $q->where('email', 'like', "%{$search}%"));
});
}
// Filter by status
if ($status = $request->input('status')) {
$query->where('status', $status);
}
// Filter by plan
if ($planId = $request->input('plan_id')) {
$query->where('plan_id', $planId);
}
$tenants = $query->orderByDesc('created_at')->paginate(25);
return view('admin.tenants.index', [
'tenants' => $tenants,
]);
}
public function show(Tenant $tenant)
{
$tenant->load(['owner', 'plan', 'users', 'projects']);
$subscription = $tenant->subscription('default');
return view('admin.tenants.show', [
'tenant' => $tenant,
'subscription' => $subscription,
'invoices' => $tenant->hasStripeId() ? $tenant->invoices() : [],
]);
}
public function suspend(Tenant $tenant)
{
$tenant->update(['status' => 'suspended']);
return back()->with('success', "Tenant suspended: {$tenant->name}");
}
public function activate(Tenant $tenant)
{
$tenant->update(['status' => 'active']);
return back()->with('success', "Tenant activated: {$tenant->name}");
}
public function impersonate(Tenant $tenant)
{
$admin = auth()->user();
// Save admin session so we can return later
session(['admin_impersonating' => $admin->id]);
// Login as tenant owner
auth()->login($tenant->owner);
return redirect()->route('dashboard')
->with('info', "Impersonating: {$tenant->name}");
}
public function stopImpersonating()
{
$adminId = session('admin_impersonating');
if ($adminId) {
$admin = \App\Domain\User\Models\User::findOrFail($adminId);
auth()->login($admin);
session()->forget('admin_impersonating');
}
return redirect()->route('admin.dashboard')
->with('success', 'Returned to admin account.');
}
}
6. Admin Routes
// routes/web.php
Route::prefix('admin')
->middleware(['auth', 'admin'])
->name('admin.')
->group(function () {
Route::get('/', [AdminDashboardController::class, 'index'])->name('dashboard');
// Tenant management
Route::get('/tenants', [TenantManagementController::class, 'index'])->name('tenants.index');
Route::get('/tenants/{tenant}', [TenantManagementController::class, 'show'])->name('tenants.show');
Route::post('/tenants/{tenant}/suspend', [TenantManagementController::class, 'suspend'])->name('tenants.suspend');
Route::post('/tenants/{tenant}/activate', [TenantManagementController::class, 'activate'])->name('tenants.activate');
// Impersonation
Route::post('/tenants/{tenant}/impersonate', [TenantManagementController::class, 'impersonate'])->name('tenants.impersonate');
Route::post('/stop-impersonating', [TenantManagementController::class, 'stopImpersonating'])->name('stop-impersonating');
// Metrics API (for charts)
Route::get('/api/metrics/mrr', fn () => response()->json(
app(MetricsService::class)->mrrOverTime()
))->name('api.metrics.mrr');
Route::get('/api/metrics/signups', fn () => response()->json(
app(MetricsService::class)->signupsOverTime()
))->name('api.metrics.signups');
});
7. Dashboard View
{{-- resources/views/admin/dashboard.blade.php --}}
@extends('layouts.admin')
@section('content')
<div class="max-w-7xl mx-auto py-8 px-4">
<h1 class="text-3xl font-bold mb-8">Admin Dashboard</h1>
{{-- KPI Cards --}}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-sm text-gray-500">MRR</p>
<p class="text-3xl font-bold text-green-600">
${{ number_format($overview['mrr'], 0) }}
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-sm text-gray-500">ARR</p>
<p class="text-3xl font-bold">
${{ number_format($overview['arr'], 0) }}
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-sm text-gray-500">Active Tenants</p>
<p class="text-3xl font-bold">{{ $overview['active_tenants'] }}</p>
<p class="text-xs text-gray-400">
+{{ $overview['trial_tenants'] }} on trial
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-sm text-gray-500">Total Users</p>
<p class="text-3xl font-bold">{{ $overview['total_users'] }}</p>
</div>
</div>
{{-- Secondary KPIs --}}
<div class="grid grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-sm text-gray-500">Churn Rate (this month)</p>
<p class="text-2xl font-bold {{ $overview['churn_rate'] > 5 ? 'text-red-500' : 'text-green-600' }}">
{{ $overview['churn_rate'] }}%
</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-sm text-gray-500">Trial → Paid Conversion</p>
<p class="text-2xl font-bold">{{ $overview['trial_conversion_rate'] }}%</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-sm text-gray-500">ARPU</p>
<p class="text-2xl font-bold">
${{ $overview['active_tenants'] > 0
? number_format($overview['mrr'] / $overview['active_tenants'], 2)
: '0' }}
</p>
</div>
</div>
{{-- Revenue by Plan --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="font-semibold mb-4">Revenue by Plan</h3>
<table class="w-full text-sm">
<thead>
<tr class="text-left text-gray-500 border-b dark:border-gray-700">
<th class="pb-2">Plan</th>
<th class="pb-2">Tenants</th>
<th class="pb-2 text-right">MRR</th>
</tr>
</thead>
<tbody>
@foreach($revenueByPlan as $plan)
<tr class="border-b dark:border-gray-700">
<td class="py-2 font-medium">{{ $plan['plan'] }}</td>
<td class="py-2">{{ $plan['count'] }}</td>
<td class="py-2 text-right">${{ number_format($plan['mrr'], 0) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
{{-- Signups Chart --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 class="font-semibold mb-4">Signups (last 30 days)</h3>
<canvas id="signupsChart" height="200"></canvas>
</div>
</div>
{{-- Top Tenants --}}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="font-semibold">Top Tenants</h3>
<a href="{{ route('admin.tenants.index') }}" class="text-sm text-blue-600">
View all →
</a>
</div>
<table class="w-full text-sm">
<thead>
<tr class="text-left text-gray-500 border-b dark:border-gray-700">
<th class="pb-2">Tenant</th>
<th class="pb-2">Plan</th>
<th class="pb-2">Members</th>
<th class="pb-2">Status</th>
<th class="pb-2">Created</th>
</tr>
</thead>
<tbody>
@foreach($topTenants as $tenant)
<tr class="border-b dark:border-gray-700">
<td class="py-2">
<a href="{{ route('admin.tenants.show', $tenant['id']) }}" class="font-medium text-blue-600 hover:underline">
{{ $tenant['name'] }}
</a>
<span class="text-xs text-gray-400 block">{{ $tenant['slug'] }}</span>
</td>
<td class="py-2">{{ $tenant['plan'] }}</td>
<td class="py-2">{{ $tenant['users_count'] }}</td>
<td class="py-2">
<span class="px-2 py-0.5 rounded-full text-xs
{{ $tenant['status'] === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' }}">
{{ $tenant['status'] }}
</span>
</td>
<td class="py-2 text-gray-500">{{ $tenant['created_at'] }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
{{-- Chart.js --}}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script>
const signupsData = @json($signupsChart);
new Chart(document.getElementById('signupsChart'), {
type: 'bar',
data: {
labels: signupsData.map(d => d.date),
datasets: [{
label: 'Signups',
data: signupsData.map(d => d.count),
backgroundColor: 'rgba(59, 130, 246, 0.5)',
borderColor: 'rgb(59, 130, 246)',
borderWidth: 1,
}]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
plugins: { legend: { display: false } },
}
});
</script>
@endsection
8. Health Check Endpoint
// app/Http/Controllers/Admin/HealthCheckController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
class HealthCheckController extends Controller
{
public function __invoke()
{
$checks = [
'database' => $this->checkDatabase(),
'cache' => $this->checkCache(),
'queue' => $this->checkQueue(),
'storage' => $this->checkStorage(),
'stripe' => $this->checkStripe(),
];
$allHealthy = collect($checks)->every(fn ($check) => $check['status'] === 'ok');
return response()->json([
'status' => $allHealthy ? 'healthy' : 'degraded',
'checks' => $checks,
'timestamp' => now()->toIso8601String(),
], $allHealthy ? 200 : 503);
}
private function checkDatabase(): array
{
try {
$start = microtime(true);
DB::select('SELECT 1');
$time = round((microtime(true) - $start) * 1000, 2);
return ['status' => 'ok', 'response_time_ms' => $time];
} catch (\Exception $e) {
return ['status' => 'error', 'message' => 'Database unreachable'];
}
}
private function checkCache(): array
{
try {
Cache::put('health_check', true, 10);
return Cache::get('health_check')
? ['status' => 'ok']
: ['status' => 'error', 'message' => 'Cache read failed'];
} catch (\Exception $e) {
return ['status' => 'error', 'message' => 'Cache unreachable'];
}
}
private function checkQueue(): array
{
try {
$size = Queue::size();
return [
'status' => $size < 1000 ? 'ok' : 'warning',
'queue_size' => $size,
];
} catch (\Exception $e) {
return ['status' => 'error', 'message' => 'Queue unreachable'];
}
}
private function checkStorage(): array
{
$free = disk_free_space(storage_path());
$total = disk_total_space(storage_path());
$usedPercent = round((1 - $free / $total) * 100, 1);
return [
'status' => $usedPercent < 90 ? 'ok' : 'warning',
'disk_usage_percent' => $usedPercent,
'free_gb' => round($free / 1073741824, 2),
];
}
private function checkStripe(): array
{
try {
\Stripe\Stripe::setApiKey(config('cashier.secret'));
\Stripe\Balance::retrieve();
return ['status' => 'ok'];
} catch (\Exception $e) {
return ['status' => 'error', 'message' => 'Stripe API unreachable'];
}
}
}
9. Admin Notifications
Send notifications when important events occur:
// app/Domain/Admin/Notifications/DailyMetricsNotification.php
<?php
declare(strict_types=1);
namespace App\Domain\Admin\Notifications;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class DailyMetricsNotification extends Notification
{
public function __construct(
private readonly array $metrics,
) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage())
->subject("Daily SaaS Metrics — {$this->metrics['date']}")
->line("**MRR**: \${$this->metrics['mrr']}")
->line("**New signups today**: {$this->metrics['new_signups']}")
->line("**Active tenants**: {$this->metrics['active_tenants']}")
->line("**Churn rate**: {$this->metrics['churn_rate']}%")
->action('View Dashboard', route('admin.dashboard'));
}
}
Schedule the daily report:
// routes/console.php
Schedule::call(function () {
$metrics = app(MetricsService::class);
$overview = $metrics->overview();
$adminEmails = config('admin.emails');
foreach ($adminEmails as $email) {
$admin = User::where('email', $email)->first();
$admin?->notify(new DailyMetricsNotification([
'date' => now()->format('Y-m-d'),
'mrr' => $overview['mrr'],
'new_signups' => Tenant::whereDate('created_at', today())->count(),
'active_tenants' => $overview['active_tenants'],
'churn_rate' => $overview['churn_rate'],
]));
}
})->dailyAt('08:00');
10. Testing Admin
// tests/Feature/AdminDashboardTest.php
<?php
use App\Domain\Tenant\Actions\CreateTenantAction;
use App\Domain\User\Models\User;
test('non-admin cannot access admin dashboard', function () {
$user = User::factory()->create(['email' => 'regular@test.com']);
$this->actingAs($user)
->get('/admin')
->assertForbidden();
});
test('admin can access dashboard', function () {
config(['admin.emails' => ['admin@test.com']]);
$admin = User::factory()->create(['email' => 'admin@test.com']);
$this->actingAs($admin)
->get('/admin')
->assertOk()
->assertSee('Admin Dashboard');
});
test('admin can view tenant details', function () {
config(['admin.emails' => ['admin@test.com']]);
$admin = User::factory()->create(['email' => 'admin@test.com']);
$user = User::factory()->create();
$tenant = (new CreateTenantAction())->execute($user, 'Test Co');
$this->actingAs($admin)
->get("/admin/tenants/{$tenant->id}")
->assertOk()
->assertSee('Test Co');
});
test('admin can suspend tenant', function () {
config(['admin.emails' => ['admin@test.com']]);
$admin = User::factory()->create(['email' => 'admin@test.com']);
$user = User::factory()->create();
$tenant = (new CreateTenantAction())->execute($user, 'Bad Tenant');
$this->actingAs($admin)
->post("/admin/tenants/{$tenant->id}/suspend")
->assertRedirect();
expect($tenant->fresh()->status)->toBe('suspended');
});
test('health check returns healthy status', function () {
config(['admin.emails' => ['admin@test.com']]);
$admin = User::factory()->create(['email' => 'admin@test.com']);
$this->actingAs($admin)
->get('/admin/health')
->assertOk()
->assertJson(['status' => 'healthy']);
});
Summary
In Part 6, we built:
- MetricsService to calculate MRR, ARR, churn rate, conversion rate
- Admin dashboard with KPI cards, charts, top tenants
- Tenant management: search, filter, suspend, activate
- Impersonation for debugging customer issues
- Health check endpoint for monitoring
- Daily metrics email for admins
- Charts with Chart.js
Key metrics every SaaS needs to track:
- MRR (Monthly Recurring Revenue) — metric #1
- Churn rate — should be < 5%/month
- Trial conversion rate — target > 20%
- ARPU (Average Revenue Per User)
Next up: API & Webhooks — building REST APIs for third-party integrations and a webhook system.