Appearance
Architecture overview
Everything runs in one Laravel app and one database. Isolation between clients comes from site_id on every site-owned row, per-site roles, and a separate S3 bucket per site.
Stack
- Laravel 13, PHP 8.3+ (spatie/laravel-activitylog v5 needs PHP 8.4+)
- Filament 4 (two panels)
spatie/laravel-permissionin teams modelaravel/sanctum,dedoc/scramble(API docs),league/flysystem-aws-s3-v3spatie/laravel-activitylogfor the audit trail
The two panels
| Tenant panel | System panel | |
|---|---|---|
| URL | /admin/{site-slug}/... | /system |
| Panel id | admin | |
| Tenancy | Site is the tenant (->tenant(Site::class, slugAttribute: 'slug')) | None |
| Who | Client admins, editors, publishers. Super admins can enter every active site. | Super admin only |
| What | Pages, packages, posts, media library, team, site settings, audit trail | Sites, users (with "Site access" repeater), roles, platform dashboard, platform-wide audit trail |
A site_user pivot links users to sites (many to many). Super admins enter any active site without a site_user row.
Anatomy of a request in the tenant panel
Data model (simplified)
Notes:
- Every site-owned model has
site_idand asite()relationship.page_blocksandpage_revisionsget their site through their page. model_has_rolescarriessite_id(Spatie's team column), which is what makes role assignments per site.activity_log.site_idis nullable. Entries with no site (login, failed login) show only in/system.sites.enabled_blocksis a JSON column listing which block types a site may use.
Cross-cutting rules
- Tenant scoping is partly manual. Resources are scoped by Filament. Widgets and hand-written queries are not.
- Roles are global definitions, assigned per site. See roles and permissions.
- Site content goes to the site's bucket, through
SiteStorage. See storage. - Dates are stored in UTC. Filament is set to display in Africa/Johannesburg. Don't change
config('app.timezone')once real data exists, or old rows will be read as the wrong time. - Public API:
/api/v1/...returns sites, a site, a page (by site public UUID and slug), packages and posts. Everything is scoped by site and returns published content only. API docs come from Scramble.
Where things live
Files added after the original build are marked (new). Check each exists in your copy, because some were provided in chat and applied one at a time.
app/
Console/Commands/ImportMedia.php (new) media:import
Enums/PageBlockType.php
Filament/
Forms/Components/LibraryFileUpload.php (new) upload field with "Choose from library"
Pages/Tenancy/EditSiteProfile.php "Site settings", includes Branding
Pages/Auth/EditProfile.php (new) profile page with avatar upload
Resources/
Pages/ (Pages/, Schemas/, Tables/, RelationManagers/)
Pages/CreatePageWizard.php (new) stepped create form
RelationManagers/RevisionsRelationManager.php (new)
Packages/ Posts/ Users/
Media/ (new) media library
AuditLogs/ (new) site-scoped audit trail
System/Resources/{Sites,Users,Roles,AuditLogs}/
Widgets/ System/Widgets/
Listeners/LogAuthenticationEvents.php (new)
Models/
Concerns/LogsSiteActivity.php (new)
Activity.php Branding.php Media.php PageRevision.php (new)
Policies/ Page, Package, Post, Site, User, Role,
Media, Activity, PageRevision (last three new)
Services/
MediaService.php SiteStorage.php SiteBranding.php UserSiteAccess.php
MediaUsage.php BrandedAvatarProvider.php (last two new)
resources/css/filament/admin/theme.css Sidebar and card styling