User: 'workspace stays in URL only until tab change' + 'use random 25-char
string or UUID for workspace identifier'.
Migration:
- workspaces.public_id: varchar(25) unique nullable.
- Backfill existing rows with Str::random(25).
Workspace model:
- Auto-fill public_id on create via booted() with collision-check loop.
- Override getRouteKeyName() → 'public_id' so route-model-binding uses
the opaque public_id everywhere (not the guessable slug).
Routes:
- Authed routes wrapped in prefix 'w/{wsId}' → URL shape
/w/bPSyuQpMDI0bl0LBKlwVOLdsl/dashboard etc.
- Bare /dashboard /sites /servers etc. now redirect to current
workspace's prefixed URL (covers external bookmarks / first visit).
ResolveCurrentWorkspace middleware:
- Resolution priority: route param {wsId} → ?workspace query →
session sticky → user.current_workspace_id → first owned.
- On resolve: URL::defaults(['wsId' => public_id]) auto-injects param
into every route() call, so all Volt wire:navigate / @route()
helpers carry the workspace through the entire SPA session.
- forceFill + saveQuietly to avoid triggering observers on user.
Sidebar:
- Workspace-switch links now point to /w/{public_id}/dashboard (not
the slug ?workspace=…).
TestCase:
- URL::defaults({wsId: 'test-workspace'}) so route('dashboard') etc.
generates valid URLs in tests.
- actingAsWithWorkspace() helper for tests that need a real workspace
bound to a logged-in user.
Browser-verified: click Sidebar → Sites navigates to
/w/bPSyuQpMDI0bl0LBKlwVOLdsl/sites, public_id sticks across every
internal navigation. 36/36 Page-Pest still green.
User accepted 5-step plan: Workspace → Sub-Sidebar → Switcher → Plan/
Quota → Billing.
This step adds the workspace layer underneath everything:
- Workspace model with HasUuids + relations (sites/servers/clusters/
backups/members/invitations/activities) + plan-based quotas + feature
flags + isOverQuota() + hasFeature() helpers.
- Plan presets: free/starter/pro/agency/enterprise with sites,
servers, team, storage_gb, features map (audit/monitoring/
email_relay/multi_workspace/white_label/reports/sso).
- BelongsToWorkspace trait: global scope filters by app('current
Workspace'), creating-hook auto-fills workspace_id from container.
Falls back to no-scope when no workspace bound (tests, console).
- Migrations: workspaces table + workspace_id foreignUuid added to
all 10 domain tables (clusters/servers/sites/backups/team_members/
invitations/security_events/certificates/activities/crons).
- users gets current_workspace_id nullable FK.
- team_members unique changed to composite (workspace_id, user_id) so
one user can belong to multiple workspaces.
- ResolveCurrentWorkspace middleware: resolves active workspace from
?workspace=slug → session → user.current_workspace_id → first owned.
Wired into web middleware group via bootstrap/app.php.
- WorkspaceFactory + workspace_id added to all 10 domain factories
so tests/seed work without explicit workspace context.
- DatabaseSeeder rewritten: creates Marie Weber as owner of 'Acme
Agency' (Pro plan) workspace + 'Marie · Personal' (Free) workspace
for switcher demo. Binds app('currentWorkspace') so seeded sites/
servers/backups/etc. land in the right workspace.
- Sidebar component reads auth user's current workspace for the ws-
switch panel — no more hard-coded 'Acme Cluster' string.
Full Pest regression: 47/47 green. 2 workspaces seeded:
- Acme Agency (pro): 87 sites, 6 servers, 128 sec events, 102 backups
- Marie · Personal (free): 0 sites
Per user request: switch from auto-increment IDs to UUID v4 across all
models.
Models — added HasUuids trait:
- User, Cluster, Server, Site, Backup, TeamMember, Invitation,
SecurityEvent, Certificate, Activity, Cron.
Migrations — $table->id() → $table->uuid('id')->primary(),
$table->foreignId(...) → $table->foreignUuid(...) across stock laravel
users/sessions/jobs migrations + all 10 domain migrations.
Routes: sites/{site} (and any other implicit route-model binding) keeps
working since Laravel resolves by primary key as a string. Tested:
clicking a site row → URL now /sites/019e800c-520a-7022-badd-2ef9d9ed5c61.
Re-seeded: 6 users, 87 sites, 6 servers, 128 security events, 30
activities, 102 backups, 47 crons, 87 certs, 5 team members, 3 invites.
Full Pest regression: 47/47 green. Browser-verified login + sites
index + site details + WP/PHP/Files tabs with template-exact chrome.