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)
}
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
}
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_idoncreating
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);
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:
- Admin imports roster contacts (CSV/Excel)
- System generates an
AccessCode, emails/SMSes it - Parent visits
schoola.domain.com, enters code, submits their issue - Only one open issue per contact at a time (enforced by a non-closed-issue lookup)
- On issue close,
access_code.used_atis 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);
}
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
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:
- Create
Tenantrow (UUID) - Create
Domainrow (subdomain) - Switch into tenant context, run tenant migrations
- Seed default roles (admin, branch_manager, staff)
- Seed 10 default issue categories
- Create
School+ defaultBranch - Create first admin
Userwith random password - 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)
~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
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)