Skip to content

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-permission in teams mode
  • laravel/sanctum, dedoc/scramble (API docs), league/flysystem-aws-s3-v3
  • spatie/laravel-activitylog for the audit trail

The two panels ​

Tenant panelSystem panel
URL/admin/{site-slug}/.../system
Panel idadmin
TenancySite is the tenant (->tenant(Site::class, slugAttribute: 'slug'))None
WhoClient admins, editors, publishers. Super admins can enter every active site.Super admin only
WhatPages, packages, posts, media library, team, site settings, audit trailSites, 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_id and a site() relationship. page_blocks and page_revisions get their site through their page.
  • model_has_roles carries site_id (Spatie's team column), which is what makes role assignments per site.
  • activity_log.site_id is nullable. Entries with no site (login, failed login) show only in /system.
  • sites.enabled_blocks is a JSON column listing which block types a site may use.

Cross-cutting rules ​

  1. Tenant scoping is partly manual. Resources are scoped by Filament. Widgets and hand-written queries are not.
  2. Roles are global definitions, assigned per site. See roles and permissions.
  3. Site content goes to the site's bucket, through SiteStorage. See storage.
  4. 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.
  5. 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