/projects/school-erp-scale
2021–2026
School-management ERP — a 54-module monolith made fast without a rewrite
- client:
- Commercial school-management ERP — multi-tenant SaaS, education sector
- role:
- Developer → Tech Lead & Technical Project Manager (architect)
- Laravel
- PHP
- MySQL
- Redis
- Bootstrap
- Vue.js
- Pusher
01 — the problem
What was actually at stake
The product was a school-management ERP sold as a single-school install and later stretched into multi-tenant SaaS: one codebase, 54 optional feature modules, ~517 Eloquent models. Two things had grown in lockstep — the surface area and the cost of touching it. A single page render re-checked which modules were active 2,236+ times, and each check hit the session, the database, and the filesystem. Under tenant load the per-request cost rose with the catalogue, not with the work the page actually did.
The status checks were not in one place you could fix. They were buried inside global query scopes that fire on every model query, inside helpers called everywhere, inside 590 controllers across the modules. Any change to the hot path was also a change to tenant isolation: the same scopes that asked 'is this module on?' also pinned each read to its school_id. Speeding them up while keeping every tenant's data separated — and not rewriting 590 controllers to do it — was the whole job.
02 — the architecture
How it was built
The fix was a single source of truth for module status, ModuleRegistry, that computes all 54 statuses once per request into a static array and serves every later check from memory — zero database, zero filesystem after the first call. The legacy moduleStatusCheck() helper was left in place and rewired to delegate to the registry, so none of the 590 callers had to change. The same registry feeds the global scopes, so the 2,236-call hot path collapsed to one batched pass without altering a single query result.
Tenancy is row-scoped, not database-per-tenant: SubdomainMiddleware resolves the current school from the subdomain and binds it into the container; global scopes (StatusAcademicSchoolScope and siblings) then pin every read to active_status, academic_id and school_id. The app runs on Octane/RoadRunner with persistent workers, which made the move to a per-request static cache a correctness exercise as much as a speed one — the registry resets per request and never leaks one tenant's state into the next. Redis backs cache and session; a database queue and a scheduler carry SMS, mail, attendance and payment-reminder work off the request.
- tenancy
- shared MySQL · row-scoped by school_id · subdomain → school
- isolation
- global scopes pin every read · active_status · academic_id · school_id
- hot path
- module status: 2,236+ checks/req → 1 batched in-memory pass
- runtime
- Octane · RoadRunner workers · Redis cache/session · DB queue
- compat
- legacy moduleStatusCheck() delegates to registry · 0 caller rewrites
03 — the scope
The full build, end to end
The platform I worked across — first as a developer, then guiding the team, then as Tech Lead and TPM — spans 54 feature modules over a shared multi-tenant core: double-entry accounting (chart of accounts, bank, income/expense), fees (installments, invoices, discounts, carry-forward, gateway collection), examinations (schedules, marks register, merit position), library, hostel, transport, HR and payroll (salary templates, leave), admissions and a leads CRM, an LMS (33 entities — courses, chapters, purchase logs), online and biometric/QR attendance, a wallet, and live-class bridges (Zoom, BigBlueButton, Jitsi, Google Meet). It integrates 13 payment gateways and 5 SMS providers behind single dispatch points, ships ~437 migrations and 2,300+ routes, and runs on Octane/RoadRunner with Redis and a database queue.
- 54
- feature modules
- 517
- Eloquent models
- 13
- payment gateways
- 437
- migrations
modules & features
- Double-entry accounting
- Chart of accounts, bank accounts, income/expense heads — SmChartOfAccount, SmBankAccount, SmAddIncome/Expense.
- Fees & collection
- Installments, invoices, discounts, carry-forward and gateway-driven payment; ~30 fees models plus a Fees module.
- Examination
- Exam schedules, marks register, merit/position calc, signatures — SmExam, SmExamMarksRegister, ExamMeritPosition; plus ExamPlan + OnlineExam modules.
- LMS
- Courses, chapters, lessons, files, comments, purchase logs — 33 entities in the Lms module, checkout via the shared gateway layer.
- HR & payroll
- Salary templates, payroll generation, earn/deduction, leave requests — SmHrSalaryTemplate, SmHrPayrollGenerate, SmLeaveRequest; SaasHr for SaaS staff.
- Library
- Books, categories, issue/return — SmBook, SmBookCategory, SmBookIssue.
- Hostel & transport
- Dormitory lists and vehicle/route assignment — SmDormitoryList, SmAssignVehicle.
- Admissions & leads
- Admission queries with follow-ups and a leads CRM with reminders — SmAdmissionQuery, Lead module (scheduled lead:reminder).
- Attendance
- Biometric (InfixBiometrics) and QR-code (QRCodeAttendance) attendance, plus absent-notification SMS on a per-minute schedule.
- Wallet
- Per-user wallet with transactions, topped up through the payment-gateway dispatcher — WalletTransaction.
- Live classes
- Bridges to Zoom, BigBlueButton, Jitsi, Google Meet and in-app live class as separate modules.
- Payment gateways
- 13 gateways behind a config-mapped dispatcher: Stripe, PayPal, Paystack, RazorPay, MercadoPago, CcAvenue, PhonePe, SslCommerz, Xendit, Khalti, MomoPay, ToyyibPay, Raudhah.
- SMS & notifications
- One send_sms() entry point routing Twilio, Msg91, TextLocal, AfricasTalking, Himalaya; FCM push; notification fan-out.
- SaaS & multi-tenancy
- Subdomain-resolved schools, plan permissions, subscription gating — Saas module + SubdomainMiddleware + global scopes.
04 — the visuals
What it looks like


05 — from the codebase
From the codebase
The defining change: per-request batching collapses 2,236+ module-status lookups (session + DB + filesystem) into one in-memory pass; the legacy helper delegates here, so no caller changed.
// app/Support/ModuleRegistry.php
// moduleStatusCheck() ran 2,236+ times per request — each call hit the
// session, the DB, and the filesystem. Compute every status ONCE per request.
class ModuleRegistry
{
// Per-request, populated on first access, never re-queried.
private static ?array $statuses = null;
public static function isActive(string $module): bool
{
if (self::$statuses === null) {
self::boot(); // first call only
}
return self::$statuses[$module] ?? false; // pure memory lookup
}
private static function boot(): void
{
self::$statuses = [];
try {
foreach (self::getAllModuleNames() as $name) {
self::$statuses[$name] = self::computeStatus($name);
}
} catch (\Throwable $e) {
self::$statuses = []; // never crash the app over a status check
}
}
// Legacy moduleStatusCheck() now delegates here — zero caller changes.
}Multi-tenancy enforced at the query layer: every read on ~517 models is pinned to school_id, with the hot module check served from the registry, not the DB.
// app/Scopes/StatusAcademicSchoolScope.php — runs on EVERY query of a scoped model.
public function apply(Builder $builder, Model $model): void
{
$table = $model->getTable();
$academicCol = $table.'.academic_id';
// University mode swaps the academic column — registry lookup, not a DB hit.
if (ModuleRegistry::isActive('University')
&& in_array('un_academic_id', Schema::getColumnListing($table), true)) {
$academicCol = $table.'.un_academic_id';
}
if (! Auth::check()) {
$builder->where($table.'.active_status', 1);
return;
}
$user = Auth::user();
$isSuperAdmin = ModuleRegistry::isActive('Saas')
&& $user->is_administrator === 'yes' && $user->role_id === 1;
// Tenant isolation: every read is pinned to the user's school_id.
$builder->where($table.'.active_status', 1)
->where($academicCol, getAcademicId())
->when(! $isSuperAdmin, fn ($q) => $q->where($table.'.school_id', $user->school_id));
}Integration layer: payment methods resolve to a gateway class through a config map and a uniform handle() contract, so adding a gateway is a config entry plus one class.
// config/paymentGateway.php — method name → gateway class.
return [
'Stripe' => App\PaymentGateway\StripePayment::class,
'PayPal' => App\PaymentGateway\PaypalPayment::class,
'Paystack' => App\PaymentGateway\PaystackPayment::class,
'RazorPay' => App\PaymentGateway\RazorPayPayment::class,
'MercadoPago' => App\PaymentGateway\MercadoPagoPayment::class,
'SslCommerz' => App\PaymentGateway\SslCommerz::class,
// …Xendit, CcAvenue, PhonePe (+ Khalti, MomoPay, ToyyibPay, Raudhah modules)
];
// app/Http/Controllers/GatewayPaymentController.php
$data['method'] = SmPaymentMethhod::find($request->payment_method)->method;
$classMap = config('paymentGateway.'.$data['method']); // one resolve point
$make_payment = new $classMap();
// Every gateway honours the same contract — callers never branch per provider.
return $make_payment->handle($data);Integration resilience: a single per-tenant entry point routes across five SMS providers, and provider failures are swallowed so an SMS outage never breaks the calling flow.
// app/Helpers/SmsHelper.php — one entry point, five providers behind a runtime switch.
function send_sms(?string $receiver, $purpose, $data): void
{
if (! $receiver) { return; }
$schoolId = Auth::check() && saasSettings('sms_settings') ? Auth::user()->school_id : 1;
$gateway = SmSmsGateway::where('school_id', $schoolId)->where('active_status', 1)->first();
if (! $gateway) { return; } // no gateway configured → caller is never blocked
$body = SmsTemplate::smsTempleteToBody(getTempleteDetails($purpose, 'sms')->body, $data);
try {
if ($gateway->gateway_name === 'Twilio') {
(new Client($gateway->twilio_account_sid, $gateway->twilio_authentication_token))
->messages->create($receiver, [
'from' => $gateway->twilio_registered_no,
'body' => $body
]);
} elseif ($gateway->gateway_name === 'Msg91') { /* HTTP GET to msg91 */ }
elseif ($gateway->gateway_name === 'TextLocal') { /* .in / .com per type */ }
elseif ($gateway->gateway_name === 'AfricaTalking') { /* SDK call */ }
elseif ($gateway->gateway_name === 'Himalayasms') { /* HTTP call */ }
} catch (\Throwable $e) {
// Swallow provider errors: an SMS outage must never break the calling flow.
}
}06 — the outcome
What the numbers say
- module-status checks per request, after batching
- 2,236→1
- feature modules from one multi-tenant codebase
- 54
- payment gateways · one school per subdomain
- 13 / 1
A heavy inherited ERP kept adding modules and tenants without the per-request cost growing with them — and nothing in 590 controllers had to be rewritten to get there.