DEV Community

Sharjeel Zubair
Sharjeel Zubair

Posted on

I Built a Multi-Tenant School Helpdesk in Laravel — Here's the Full Stack

I open-sourced a production-grade, multi-tenant SaaS last week. Here's a tour of what's inside — and every non-obvious architecture decision along the way.

Meet Schoolytics

Schoolytics is an open-source helpdesk for schools. One platform runs an entire district — each school is isolated by subdomain, runs its own branded portal, and has its own users, issues, and CSAT data.

Think Zendesk meets Linear meets a parent-teacher app — MIT-licensed, self-hostable, and free forever.

  • 🏫 Multi-tenant — one deploy, unlimited schools, subdomain-isolated
  • 🔑 Passwordless parent portal — one-time access codes, no app, no account
  • 🤖 AI triage — Python sentiment microservice scores every issue on submission
  • 🧑‍💼 Three-tier RBAC — admin / branch manager / staff, enforced in queries AND policies
  • 📧 Full email stack — transactional mails, in-app notifications, CSAT surveys
  • 🎛 Nova super-admin — provision a new school (tenant + domain + admin + 10 categories + demo data) with ONE click

The stack

Layer Choice Why
Framework Laravel 12 / PHP 8.2 Fastest iteration, Nova, first-party queue/mail/auth
DB PostgreSQL 16 Row-level multi-tenancy, case-insensitive ilike, check constraints
Multi-tenancy stancl/tenancy v3 Battle-tested, subdomain-based, row-level scoping
RBAC Spatie Permission (teams mode) Team-scoped permissions = tenant-scoped for free
Super-admin Laravel Nova Built-in resource CRUD, custom actions for provisioning
Frontend Blade + Alpine + Tailwind + ECharts No SPA overhead, ships fast, renders on the server
Queue Database driver (dev), Redis/SQS (prod) One less moving piece locally
AI FastAPI (Python) Isolated, swappable, doesn't bloat Laravel

The interesting decisions

1. Two auth guards, zero overlap

Most multi-tenant apps put super-admins in the same users table with a is_superadmin flag. That's a mistake the first time a bug lets a tenant query return a central user.

Schoolytics has two completely separate models:

// Guard: central → entry: /nova
class CentralUser extends Authenticatable { /* table: central_users */ }

// Guard: web → entry: /admin/login (tenant subdomain)
class User extends Authenticatable {
    use BelongsToTenant, HasRoles;
    // table: users (row-level tenant_id)
}
Enter fullscreen mode Exit fullscreen mode

CentralUser can't have Spatie roles (teams mode requires a tenant context), so the IssuePolicy has a before() hook:

public function before(?Authenticatable $user): ?bool
{
    if ($user instanceof CentralUser) return true; // full access
    return null; // fall through to normal policy methods
}
Enter fullscreen mode Exit fullscreen mode

Two guards. Zero cross-contamination. Superadmin can debug anything without ever needing a tenant role.

2. Row-level multi-tenancy, enforced twice

Every tenant-scoped model uses a BelongsToTenant trait that:

  • Adds a global scope filtering WHERE tenant_id = tenant('id')
  • Auto-fills tenant_id on creating

But I don't trust global scopes alone. For sensitive actions I also do an explicit check:

abort_unless($issue->tenant_id === tenant('id'), 404);
Enter fullscreen mode Exit fullscreen mode

Defense in depth. If someone ever calls withoutGlobalScopes() by accident, the explicit check still catches it.

3. Passwordless parent flow

Parents aren't technical. They don't want accounts. They lose passwords. So:

  1. Admin imports roster contacts (CSV/Excel)
  2. System generates an AccessCode, emails/SMSes it
  3. Parent visits schoola.domain.com, enters code, submits their issue
  4. Only one open issue per contact at a time (enforced by a non-closed-issue lookup)
  5. On issue close, access_code.used_at is reset — parent can submit again with the same code

The UX is identical to a "magic link" but synchronous and auditable.

4. Role-scoped queries in ONE place

Three roles see three different slices:

  • admin — every issue in the tenant
  • branch_manager — issues in branches they manage
  • staff — only issues assigned to them

One query scope handles all of it:

public function scopeVisibleTo(Builder $q, User $user): Builder
{
    if ($user->hasRole('admin')) return $q;

    if ($user->hasRole('branch_manager')) {
        return $q->whereIn('branch_id', $user->branches->pluck('id'));
    }

    return $q->where('assigned_user_id', $user->id);
}
Enter fullscreen mode Exit fullscreen mode

Every Issue::query() in every controller calls ->visibleTo(auth()->user()). Forget it once, and the policy still blocks the action. Two layers.

5. AI triage via a separate microservice

When a parent submits an issue, a queued listener POSTs to a FastAPI service:

Laravel  ──event──► PerformAiAnalysis (queued)  ──HTTP──►  Python FastAPI
                                                               │
                                                               ▼
                                                  {sentiment, category, confidence}
                                                               │
                                                               ▼
                                                  IssueAiAnalysis row
Enter fullscreen mode Exit fullscreen mode

Why separate? Because:

  • Python's ML libraries are in Python, not PHP
  • I can swap the model (fine-tune later) without touching Laravel
  • The HTTP boundary forces me to think about failure modes (timeouts, retries, circuit breakers)
  • The sentiment service is stateless — horizontally scalable on its own

6. Nova actions that actually save time

Instead of a 5-step manual tenant setup, ProvisionTenant does it all:

  1. Create Tenant row (UUID)
  2. Create Domain row (subdomain)
  3. Switch into tenant context, run tenant migrations
  4. Seed default roles (admin, branch_manager, staff)
  5. Seed 10 default issue categories
  6. Create School + default Branch
  7. Create first admin User with random password
  8. Email the admin their credentials via TenantProvisionedMail

One click. Thirty seconds. A new school is live.

GenerateDemoData is the cooler one — it seeds a tenant with realistic data matched to the 10 default categories (e.g., Transport gets "Bus late", Academics gets "Math homework concern"), creates branch managers, staff assigned to categories, parents and teachers each with an open issue. You can demo the product to a school in 60 seconds.

7. The queue + tenancy trap

This one bit me hard enough that it got its own postmortem post. TL;DR: never put a tenant-scoped Eloquent model in a queued job's constructor. Pass scalars, call tenancy()->initialize() in handle().

What's in the repo

app/
  Http/Controllers/          → tenant + public portal controllers
  Models/                    → User, Issue, RosterContact, AccessCode, ...
  Policies/                  → IssuePolicy, RosterContactPolicy, ...
  Nova/                      → Tenant, Domain, CentralUser resources + actions
  Jobs/                      → AnalyzeIssueSentiment, LogActivityJob
  Listeners/                 → PerformAiAnalysis
  Mail/                      → 6 transactional mailables
database/
  migrations/                → central schema
  migrations/tenant/         → per-tenant schema
  seeders/                   → roles, default categories, demo data
routes/
  web.php                    → /nova + central admin
  tenant.php                 → /admin + public portal (per-tenant)
Enter fullscreen mode Exit fullscreen mode

~30 migrations, ~40 models, ~20 controllers, ~15 policies, full Nova suite.

What I'd do differently next time

  • Start with Redis for queue — the database driver was fine until demo day, when AI jobs stacked up
  • Write the tenancy feature test FIRST — the one that actually boots a queue worker with no tenant context. Would have caught the queue trap in minutes instead of hours.
  • Pick Livewire over Blade+Alpine for the admin — the filter-form dance gets old; Livewire would halve the code
  • Use Inertia for the parent portal — it's the one place where a SPA-ish feel would help UX

Try it

git clone https://github.com/sharjeelz/eliflammeem-git.git
cd eliflammeem-git
composer install && npm install
cp .env.example .env && php artisan key:generate
php artisan migrate && php artisan db:seed
composer run dev
Enter fullscreen mode Exit fullscreen mode

Then open http://central.lvh.me:8000/nova. Provision a tenant. Watch the whole thing come alive.

Contribute

⭐ the repo if this saved you a weekend. PRs welcome — the roadmap is on the README:

  • Arabic + RTL UI
  • WhatsApp Business API for access-code delivery
  • SSO for staff
  • Per-tenant custom branding

🔗 github.com/sharjeelz/eliflammeem-git

What would you have done differently? Drop a comment — genuinely want to hear it.

Top comments (0)