A team dashboard should answer one question quickly: what needs attention right now?
For this walkthrough, we will build RelayDesk, an incident and task dashboard for a digital agency. Teams can track support incidents, assign owners, monitor response times, and review recent activity.
The stack is Laravel 13, Vue, Inertia 3, Pinia, and Tailwind. It uses Laravel’s server-side routing and authentication, while Vue handles the interactive dashboard experience.
Laravel remains a strong PHP web framework for this kind of application. It provides routing, authorization, sessions, queues, validation, and database tooling without forcing you to assemble every layer yourself.
The architecture: server-first, interactive where needed
RelayDesk has four main layers:
- Laravel owns routes, policies, queries, sessions, and business rules.
- Inertia transports server-rendered page data to Vue without a separate frontend router.
- Vue renders dashboard widgets and handles local interaction.
- Pinia stores state shared across filters, widgets, and layouts.
The first request returns the dashboard shell and its critical metrics. Expensive reports load afterward through deferred props. Status cards refresh through usePoll. Small actions, such as assigning an incident, use useHttp without navigating away.

The Laravel Vue starter kit provides a useful starting point. It includes Vue, Inertia 3, the Composition API, TypeScript, Tailwind, and shadcn-vue components. You can also begin with a fresh application and add the pieces manually.
laravel new relaydesk
cd relaydesk
npm install
npm run build
composer run dev
For a production dashboard, keep the domain model straightforward:
Team
User
Incident
IncidentComment
Activity
SlaReport
An Incident belongs to a team and has an owner. Its status might be open, in_progress, blocked, or resolved.
Authentication: use sessions for the dashboard
RelayDesk is a browser application. Its main dashboard should use Laravel’s session-based web guard, not a token for every page request.
The starter kits use Laravel Fortify for login, registration, password resets, email verification, and two-factor authentication. Protect the dashboard with auth and verified middleware:
// routes/web.php
use App\Http\Controllers\TeamDashboardController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth:web', 'verified'])
->group(function () {
Route::get('/{team:slug}/dashboard', TeamDashboardController::class)
->name('dashboard');
});
Teams need authorization beyond authentication. A signed-in user should not access another team by changing a URL slug.
// app/Policies/TeamPolicy.php
public function view(User $user, Team $team): bool
{
return $team->users()
->whereKey($user->id)
->exists();
}
Then authorize the team inside the controller:
use Illuminate\Support\Facades\Gate;
public function __invoke(Team $team)
{
Gate::authorize('view', $team);
// Continue building the page response...
}
This separates two concerns:
- The guard confirms who the user is.
- The policy confirms what that user may access.
The Laravel authentication documentation covers the broader starter kit flow. For custom applications, also review middleware and CSRF protection.
Return critical data first
The dashboard needs a few values immediately:
- Current team details.
- Open incident count.
- Incidents breached against their SLA.
- Active team member count.
- The latest status timestamp.
Reports and historical charts can wait. Inertia deferred props are designed for this split.
// app/Http/Controllers/TeamDashboardController.php
namespace App\Http\Controllers;
use App\Models\Team;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
use Inertia\Response;
class TeamDashboardController extends Controller
{
public function __invoke(Team $team): Response
{
Gate::authorize('view', $team);
return Inertia::render('Teams/Dashboard', [
'team' => $team->only(['id', 'name', 'slug']),
'summary' => [
'open' => $team->incidents()
->whereIn('status', ['open', 'in_progress'])
->count(),
'breached' => $team->incidents()
->where('sla_breached', true)
->whereNull('resolved_at')
->count(),
'members' => $team->users()->count(),
'updatedAt' => now()->toIso8601String(),
],
'recentIncidents' => $team->incidents()
->with('owner:id,name')
->latest('updated_at')
->limit(8)
->get(),
'weeklyReport' => Inertia::defer(
fn () => app(TeamReport::class)->weekly($team),
rescue: true
),
'activity' => Inertia::defer(
fn () => $team->activities()
->with('user:id,name')
->latest()
->limit(30)
->get(),
rescue: true
),
]);
}
}
The weeklyReport and activity props are loaded in a follow-up request. The rescue: true option prevents a slow reporting query or temporary service failure from breaking the entire dashboard.
You can group deferred props when they should load together. Use separate groups when reports should run in parallel:
'weeklyReport' => Inertia::defer(
fn () => app(TeamReport::class)->weekly($team),
'reports',
rescue: true
),
'activity' => Inertia::defer(
fn () => $team->activities()->latest()->limit(30)->get(),
'activity',
rescue: true
),
Read the Inertia deferred props documentation for the complete server and client API.
Build the Vue page with fallback and rescue states
The page receives its initial data as props. Slow sections should never leave an empty rectangle.
<script setup lang="ts">
import { Deferred, Head, router } from '@inertiajs/vue3'
import SummaryCards from '@/components/SummaryCards.vue'
import IncidentTable from '@/components/IncidentTable.vue'
import WeeklyReport from '@/components/WeeklyReport.vue'
import ActivityFeed from '@/components/ActivityFeed.vue'
defineProps<{
team: {
id: number
name: string
slug: string
}
summary: {
open: number
breached: number
members: number
updatedAt: string
}
recentIncidents: Array<Record<string, unknown>>
weeklyReport?: Record<string, unknown>
activity?: Array<Record<string, unknown>>
}>()
</script>
<template>
<Head :title="`${team.name} dashboard`" />
<main class="space-y-6">
<SummaryCards :summary="summary" />
<IncidentTable :incidents="recentIncidents" />
<section class="rounded-xl border bg-white p-6">
<h2 class="text-lg font-semibold">Weekly report</h2>
<Deferred data="weeklyReport">
<template #fallback>
<div class="h-48 animate-pulse rounded-lg bg-slate-100" />
</template>
<template #rescue="{ reloading }">
<div class="space-y-3 py-8 text-sm text-slate-600">
<p>The report is temporarily unavailable.</p>
<button
class="rounded-md bg-slate-900 px-3 py-2 text-white disabled:opacity-50"
:disabled="reloading"
@click="router.reload({ only: ['weeklyReport'] })"
>
{{ reloading ? 'Retrying…' : 'Try again' }}
</button>
</div>
</template>
<WeeklyReport :report="weeklyReport" />
</Deferred>
</section>
<section class="rounded-xl border bg-white p-6">
<h2 class="text-lg font-semibold">Recent activity</h2>
<Deferred data="activity">
<template #fallback>
<p class="py-8 text-sm text-slate-500">
Loading activity…
</p>
</template>
<template #rescue="{ reloading }">
<div class="flex items-center justify-between py-8">
<p class="text-sm text-slate-600">
Activity could not be loaded.
</p>
<button
:disabled="reloading"
@click="router.reload({ only: ['activity'] })"
>
Retry
</button>
</div>
</template>
<ActivityFeed :entries="activity" />
</Deferred>
</section>
</main>
</template>
The fallback slot handles the initial loading state. The rescue slot handles a failed deferred request. Its reloading value lets you disable the retry button while Inertia requests the prop again.
That distinction matters in production. “Still loading” and “temporarily failed” need different messages.
Shared props and Pinia: keep responsibilities clear
Shared props are useful for data needed throughout the application. The authenticated user, current team, flash messages, and permission flags are good examples.
// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => [
'user' => $request->user()?->only(['id', 'name', 'email']),
],
'currentTeam' => fn () => $request->user()?->currentTeam
?->only(['id', 'name', 'slug']),
];
}
Access shared data in Vue with usePage:
import { computed } from 'vue'
import { usePage } from '@inertiajs/vue3'
const page = usePage()
const currentTeam = computed(() => page.props.currentTeam)
Use Pinia for client-owned state instead. Filters, selected incidents, panel visibility, and polling preferences should not become server props.
// resources/js/stores/dashboard.ts
import { defineStore } from 'pinia'
export const useDashboardStore = defineStore('dashboard', {
state: () => ({
statusFilter: 'active',
selectedIncidentId: null as number | null,
compactMode: false,
}),
actions: {
setStatusFilter(status: string) {
this.statusFilter = status
},
selectIncident(id: number | null) {
this.selectedIncidentId = id
},
},
})
The rule is simple: shared props describe server state. Pinia manages interface state.

Live status updates with usePoll
Incident dashboards need fresh data, but they rarely need a WebSocket connection for every widget. Inertia 3’s usePoll provides a practical middle ground.
import { usePoll } from '@inertiajs/vue3'
const { polling, stop, start } = usePoll(
10_000,
{
only: ['summary', 'recentIncidents'],
preserveScroll: true,
},
{
mode: 'rest',
},
)
The three polling modes serve different workloads:
-
overlapstarts each request on schedule, even if the previous request is still running. -
cancelaborts the previous request before starting a new one. -
restwaits for the current request to finish, then waits for the interval.
For a dashboard, rest is a safe default. It avoids stacking requests when the database becomes busy.
Inertia also throttles polling in background tabs by default. Keep that behavior for most dashboards. Use keepAlive: true only when background freshness is essential:
usePoll(
10_000,
{ only: ['summary'] },
{
mode: 'cancel',
keepAlive: false,
},
)
The Inertia polling documentation explains the timing and lifecycle options.
Use useHttp for focused JSON actions
Not every request should navigate or reload page props. Assigning an incident is a small JSON action, so expose a dedicated endpoint:
// routes/api.php
Route::middleware('auth:sanctum')
->post('/incidents/{incident}/assign', AssignIncidentController::class);
// app/Http/Controllers/AssignIncidentController.php
public function __invoke(Request $request, Incident $incident)
{
Gate::authorize('update', $incident);
$data = $request->validate([
'user_id' => ['required', 'exists:users,id'],
]);
$incident->update([
'owner_id' => $data['user_id'],
]);
return response()->json([
'incident' => $incident->fresh('owner:id,name'),
]);
}
This is also a clean example of how to build a REST API with PHP. Laravel handles routing, validation, authorization, and JSON responses while Vue consumes the result.
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3'
const { data, submit, processing, errors } = useHttp(
'post',
`/api/incidents/${incident.id}/assign`,
)
async function assign(userId: number) {
await submit({ user_id: userId })
}
</script>
Use useHttp for isolated JSON requests. Use an Inertia visit when the server should return a new page state.
SSR gives the dashboard a fast first paint
The dashboard is authenticated, so SEO is not the main concern. SSR still helps users see the layout and initial metrics sooner, especially on mobile or slower networks.
The Vue starter kit supports Inertia SSR. Build the server-rendered bundle in production:
npm run build
npm run build:ssr
SSR renders the initial Vue page on the server. The browser then hydrates it and takes over interaction.
Keep SSR-safe code in mind:
- Do not access
windowduring server rendering. - Avoid browser-only libraries at module load time.
- Create a fresh Pinia instance for each SSR request.
- Keep time-sensitive formatting consistent between server and browser.
The Laravel SSR documentation covers the supported workflow.

Production checklist
Before shipping RelayDesk, verify the details that dashboards often miss:
- Add policies for every team-owned model.
- Index
team_id,status,updated_at, andsla_breached. - Rate-limit assignment and comment endpoints.
- Test deferred failures and retry states.
- Use queues for expensive report generation.
- Log failed background requests with Laravel’s exception handler.
- Monitor slow queries and worker health with your preferred Laravel tooling.
- Run browser tests against authentication, team switching, and incident updates.
A production dashboard is not one giant Vue component. It is a set of focused server contracts, small interactive widgets, and deliberate loading states.
Laravel supplies the application foundation. Inertia keeps the boundary thin. Vue makes the interface responsive. Pinia holds local decisions. Together, they let a PHP developer ship a serious team workspace without maintaining a separate API and frontend application for every screen.