Skip to content
~/tariqul.islam
/projects

/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.

architecture — at a glance
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

Illustrated architecture overview of School-management ERP — a 54-module monolith made fast without a rewrite
fig. 01 — /projects/school-erp-scale/cover
Product screenshot of School-management ERP — a 54-module monolith made fast without a rewrite — primary view
fig. 02 — screenshot, primary view
Product screenshot of School-management ERP — a 54-module monolith made fast without a rewrite — detail view
fig. 03 — screenshot, detail view

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.

ModuleRegistry — 2,236 status checks/request, batched to one
// 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.

Global scope — tenant isolation on every query
// 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.

Payment dispatch — 13 gateways behind a config map
// 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.

send_sms() — one entry point, five providers
// 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.

/projects/school-erp-scale/next

Need something in this neighborhood?

If this case study sounds like your problem — same domain, same scale, same kind of mess — I've already made the expensive mistakes for you. Tell me where you're stuck.