Skip to content

PyroRadius — System Architecture

Version: 1.0 (Documentation) Last Updated: 2026-07-13


Table of Contents

  1. High-Level Architecture
  2. Layer-by-Layer Breakdown
  3. How Inertia.js Works
  4. Queue Architecture
  5. RADIUS Integration Flow
  6. MikroTik Integration
  7. WhatsApp Integration
  8. Component Dependency Map
  9. Data Flow Diagrams

High-Level Architecture

┌────────────────────────────────────────────────────────────────────────────────┐
│                              CLIENT BROWSERS                                   │
│                    (Admin UI / Dealer UI / Customer Portal)                    │
│                         React 18 + Inertia.js v2                               │
└────────────────────────────┬───────────────────────────────────────────────────┘
                             │  HTTPS (443) / HTTP (80) → redirect to HTTPS
┌────────────────────────────────────────────────────────────────────────────────┐
│                               NGINX (Reverse Proxy)                            │
│              /etc/nginx/sites-available/pyroradius                             │
│  • Terminates TLS                                                               │
│  • Serves public/build/* static assets directly (JS, CSS, fonts, images)       │
│  • Passes PHP requests → PHP-FPM via Unix socket                               │
│  • Gzip compression on static assets                                           │
│  • Cache-Control headers on Vite-hashed assets                                 │
└────────────────────────────┬───────────────────────────────────────────────────┘
                             │  FastCGI (Unix socket: /run/php/php8.3-fpm.sock)
┌────────────────────────────────────────────────────────────────────────────────┐
│                          PHP-FPM 8.3 (Process Manager)                         │
│  • Pool: www (or pyroradius pool)                                              │
│  • Manages PHP worker processes                                                 │
│  • Memory limit: 512M+ recommended                                             │
└────────────────────────────┬───────────────────────────────────────────────────┘
                             │  PHP execution
┌────────────────────────────────────────────────────────────────────────────────┐
│                         LARAVEL 13.8 APPLICATION                               │
│                     /var/www/pyroradius/                                       │
│                                                                                │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────────────────┐   │
│  │   HTTP Kernel   │  │  Route Layer    │  │     Middleware Stack         │   │
│  │  (bootstrap)    │  │  web.php        │  │  auth, throttle,            │   │
│  │                 │  │  portal.php     │  │  HandleInertiaRequests,     │   │
│  └────────┬────────┘  └────────┬────────┘  │  CheckPermission, etc.      │   │
│           │                   │            └─────────────────────────────┘   │
│           ▼                   ▼                                               │
│  ┌──────────────────────────────────────────────────────────────────────────┐ │
│  │                        CONTROLLERS                                       │ │
│  │  CustomerController, BillingDashboardController, InvoiceController,      │ │
│  │  PaymentController, NasController, OltController, PackageController,     │ │
│  │  WhatsAppController, ReportController, HealthController, Tr069Controller │ │
│  │  ... (30+ controllers)                                                   │ │
│  └──────────────────┬───────────────────────────────────────────────────────┘ │
│                     │  delegates to                                            │
│                     ▼                                                          │
│  ┌──────────────────────────────────────────────────────────────────────────┐ │
│  │                         SERVICES                                         │ │
│  │  BillingService, RadiusService, MikroTikService, WhatsAppService,        │ │
│  │  AuditLogger, LedgerService, BillingCycleService, FirewallService,       │ │
│  │  GenieAcsService, RouterOsService, FcmService, OpenWaService             │ │
│  └──────────────────┬───────────────────────────────────────────────────────┘ │
│                     │  uses                                                    │
│                     ▼                                                          │
│  ┌──────────────────────────────────────────────────────────────────────────┐ │
│  │                      ELOQUENT MODELS                                     │ │
│  │  Customer, User, Invoice, Payment, LedgerEntry, Package, Nas, Olt,       │ │
│  │  RadCheck, RadGroupReply, RadUserGroup, RadAcct, WhatsappTemplate,       │ │
│  │  AuditLog, Ticket, CronJob, Setting, CompanyProfile ... (40+ models)     │ │
│  └──────────────────┬───────────────────────────────────────────────────────┘ │
│                     │                            │                             │
└─────────────────────┼────────────────────────────┼─────────────────────────────┘
                      │                            │
          ┌───────────▼──────────┐    ┌────────────▼────────────┐
          │   POSTGRESQL 16      │    │       REDIS 7            │
          │   Primary datastore  │    │   Cache + Queue + Session│
          │   + FreeRADIUS DB    │    │                          │
          └──────────────────────┘    └─────────────────────────┘
                                    ┌─────────────▼────────────┐
                                    │   SUPERVISOR WORKERS      │
                                    │   (Queue consumers)       │
                                    │   php artisan queue:work  │
                                    │   (multiple processes)    │
                                    └──────────────────────────┘

Layer-by-Layer Breakdown

1. Browser / Frontend Layer

The client-side application is a React 18 single-page application. It does not communicate with the server via a traditional REST or GraphQL API. Instead, it uses Inertia.js v2, which makes navigation feel like an SPA while keeping all business logic on the Laravel server side.

Key frontend technologies: - React 18 — component-based UI rendering with hooks - Inertia.js v2 — client-side adapter that intercepts link clicks and form submissions - Tailwind CSS v3 — utility-first CSS, compiled via PostCSS in the Vite build - Headless UI v2 — accessible modal, dropdown, and combobox primitives - Lucide React v1.16 — icon system - Recharts v3.8 — chart library for traffic graphs and dashboard metrics - Axios v1.16 — HTTP client for any direct AJAX calls (file downloads, polling) - Ziggy v2.0 — exposes Laravel named routes as a JavaScript route() helper

All JS and CSS is compiled by Vite v8.0 into /var/www/pyroradius/public/build/. The manifest file public/build/manifest.json is read by the @vite blade directive to inject correct asset URLs.

2. Nginx Layer

Nginx serves as the sole entry point for all HTTP traffic.

Responsibilities: - TLS termination — all traffic is HTTPS; HTTP redirected to HTTPS. - Static asset serving — requests matching /build/* are served directly from public/build/ without hitting PHP. These assets have content-hash filenames (Vite), so they receive long cache headers (Cache-Control: max-age=31536000, immutable). - PHP proxying — all other requests are forwarded to PHP-FPM via the Unix socket using FastCGI protocol. - Rate limiting — Nginx-level rate limiting on authentication endpoints as a first line of defense. - Gzip — text/css, application/javascript, and application/json responses are gzip-compressed.

3. PHP-FPM Layer

PHP-FPM manages a pool of PHP worker processes that handle incoming FastCGI requests from Nginx. Each worker loads the Laravel application from scratch (or from opcache), processes the request through the full middleware and controller stack, and returns the response.

Key configuration: - PHP version: 8.3 (required for Laravel 13.x) - OPcache: enabled in production; opcache.validate_timestamps=0 recommended for performance - Memory limit: 512M minimum (DomPDF PDF generation and PhpSpreadsheet Excel export can be memory-intensive) - Max execution time: 120 seconds (queue workers run separately with no limit) - Upload limit: configured for Excel import files

4. Laravel Application Layer

The core of PyroRadius. Laravel 13.8 provides: - Routingroutes/web.php for admin routes, routes/portal.php for customer portal - Middleware — authentication, permission checking, Inertia handling, CSRF protection - Controllers — 30+ controllers, each responsible for a domain area - Services — business logic extracted into service classes (not in controllers) - Jobs — async tasks queued to Redis (WhatsApp sending, invoice generation, RADIUS sync) - Commands — Artisan console commands for scheduled/manual operations (20+ commands) - Models — Eloquent ORM models with relationships, casts, scopes, and observers - Policies — Laravel policies for fine-grained authorization per resource

5. PostgreSQL 16

The primary relational database. Shared with FreeRADIUS — the same PostgreSQL instance hosts both the application tables and the FreeRADIUS schema tables (radcheck, radreply, radgroupreply, radusergroup, radacct, radpostauth).

Key PostgreSQL features in use: - JSONBpermissions column on users table for per-user permission storage - Functional indexes — on JSONB fields for permission lookups - Sequences — for invoice numbers and receipt numbers (separate sequences, not autoincrement) - Advisory locks / lock_timeoutSET lock_timeout = '30s' applied on critical billing operations to prevent deadlock-induced hangs - Partial indexes — on customers for status = 'active' queries

6. Redis 7

Redis serves three roles: - CacheCACHE_DRIVER=redis. Cached values include settings, package lists, dealer summaries. - Session storageSESSION_DRIVER=redis. Admin user sessions are stored in Redis. - Queue backendQUEUE_CONNECTION=redis. All queued jobs are stored as Redis lists.

7. Supervisor (Queue Workers)

Laravel's queue workers run as background processes managed by Supervisor. Multiple worker processes consume jobs from the Redis queue. This allows: - WhatsApp message sending without blocking web requests - Large invoice batch generation running asynchronously - RADIUS sync operations triggered in background - Excel report generation without HTTP timeout concerns


How Inertia.js Works

Inertia.js eliminates the need for a separate API layer. Here is the exact request lifecycle:

FULL PAGE LOAD (first visit):
Browser → Nginx → PHP-FPM → Laravel → Controller → Inertia::render('PageName', $props)
→ Blade template (app.blade.php) renders <div id="app"> with JSON props encoded
→ React hydrates the page on the client side

SUBSEQUENT NAVIGATION (Inertia link click):
Browser (Inertia client) → XHR request with X-Inertia: true header
→ Nginx → PHP-FPM → Laravel → Controller → Inertia::render('PageName', $props)
→ Laravel returns JSON: { component: 'PageName', props: {...}, url: '...' }
→ Inertia client swaps the React component and updates the browser URL
→ No full page reload; shared state (layout, auth user) persists

Key Inertia concepts in PyroRadius: - Shared dataHandleInertiaRequests middleware shares auth user, permissions, flash messages, and app settings to every page as $page.props - Partial reloads — Inertia supports fetching only specific props on a page update (used for polling dashboard stats) - Persistent layout — the main Layout.jsx component wraps all admin pages; it persists across navigations so sidebar state is maintained - Form helperuseForm() from @inertiajs/react handles form state, validation errors from Laravel, and submission


Queue Architecture

Laravel App (web request)
        │  dispatch(new SendWhatsAppJob($message))
        │  dispatch(new SyncRadiusJob($customer))
   Redis Queue (list: queues:default / queues:high / queues:low)
        │  Supervisor monitors and restarts workers
 ┌──────────────────────────────────────────────────────┐
 │           Supervisor Worker Pool                      │
 │  Worker 1: php artisan queue:work redis --tries=3    │
 │  Worker 2: php artisan queue:work redis --tries=3    │
 │  Worker N: php artisan queue:work redis --tries=3    │
 └──────────────────────────────────────────────────────┘
        │  Job executes (WhatsApp send, RADIUS sync, etc.)
 Success → Job removed from queue
 Failure → Retry (up to --tries times) → failed_jobs table

Queue priority queues (if configured): - high — payment notifications, expiry alerts - default — standard WhatsApp sends, RADIUS sync - low — report generation, bulk campaign sending

Failed jobs are stored in the failed_jobs table and can be retried with php artisan queue:retry all.


RADIUS Integration Flow

FreeRADIUS is configured to use the PostgreSQL backend (pgsql module). It reads authentication and authorization data directly from the database tables that Laravel also writes to. There is no API between PyroRadius and FreeRADIUS — they share the same database.

PPPoE Client (Customer CPE)
        │ PPPoE Discovery (PADI/PADO/PADR/PADS)
MikroTik NAS (PPPoE Server)
        │ RADIUS Access-Request (username, password, NAS-IP, NAS-Port)
FreeRADIUS Server (same VPS: your-server-ip)
        │ Queries PostgreSQL:
        │   SELECT * FROM radcheck WHERE username = ? AND attribute = 'Cleartext-Password'
        │   SELECT * FROM radusergroup WHERE username = ?
        │   SELECT * FROM radgroupreply WHERE groupname = ? (→ rate-limit attributes)
Authentication Result: Access-Accept or Access-Reject
        │ If Accept → sends Rate-Limit, Session-Timeout, etc.
MikroTik NAS
        │ RADIUS Accounting-Request (Start) when session begins
        │ RADIUS Accounting-Request (Interim-Update) periodically
        │ RADIUS Accounting-Request (Stop) when session ends
FreeRADIUS writes to radacct table in PostgreSQL
PyroRadius reads radacct for: online session counts, traffic usage, session history

RADIUS Table Ownership:
┌──────────────────┬──────────────────────────────────────────────────────┐
│ Table            │ Written by                                           │
├──────────────────┼──────────────────────────────────────────────────────┤
│ radcheck         │ PyroRadius (RadiusService::syncCustomer)             │
│ radreply         │ PyroRadius (per-customer reply attributes)           │
│ radgroupreply    │ PyroRadius (RadiusService::syncPackage)              │
│ radusergroup     │ PyroRadius (RadiusService::groupFor)                 │
│ radacct          │ FreeRADIUS (accounting records)                      │
│ radpostauth      │ FreeRADIUS (auth log, pruned by PruneRadpostauth)    │
└──────────────────┴──────────────────────────────────────────────────────┘

RadiusService attribute sets by connection type:
- PPPoE (id:1):  Cleartext-Password, rate-limit via radgroupreply
- Static IP (id:2): Framed-IP-Address added to radreply
- CIR (id:3):   Committed rate attributes set differently in radgroupreply

Customer RADIUS Sync Flow

When a customer's package, status, password, or connection type changes in PyroRadius:

Controller (CustomerController@update)
    → RadiusService::syncCustomer($customer)
        → Delete old radcheck rows for this username
        → Insert new radcheck: username, Cleartext-Password, == , pppoe_password
        → Delete old radusergroup rows for this username
        → Insert new radusergroup: username, groupname (from package.radius_group)
        → If static IP: delete old radreply, insert Framed-IP-Address
        → If customer status is 'suspended'/'expired': remove from radcheck (blocks auth)

MikroTik Integration

PyroRadius connects to MikroTik routers (NAS devices) via two mechanisms:

RouterOS API (Primary)

PyroRadius (MikroTikService / RouterOsService)
        │  TCP port 8728 (unencrypted) or 8729 (SSL)
        │  Library: evilfreelancer/routeros-api-php ^1.7
MikroTik Router
    Operations:
    - /ip/hotspot/active/print  → list active sessions
    - /ppp/active/print         → list active PPPoE sessions
    - /interface/monitor-traffic → real-time interface traffic
    - /radius/incoming/call     → send CoA/Disconnect-Message to kick session

NAS credentials stored in:
    nas table: name, server (IP), secret, api_username, api_password (encrypted cast)

SNMP Fallback

If the RouterOS API is unreachable (port closed, wrong credentials), MikroTikService falls back to SNMP v2c to read interface traffic counters:

PyroRadius (MikroTikService::liveTraffic)
        │  UDP port 161 (SNMP GET)
        │  OID: ifHCInOctets, ifHCOutOctets per interface
MikroTik Router (SNMP agent)

Traffic Polling Architecture

Scheduler (DB-driven, cron_jobs table)
    → Dispatches PollTraffic command
        → For each active NAS:
            → MikroTikService::getActiveSessions()
            → For each active PPPoE session:
                → Match session username to customer
                → Record bytes_in, bytes_out to customer_traffic_readings
        → Dispatches RecordSysStats command
            → CPU%, memory%, uptime stored per NAS

WhatsApp Integration

PyroRadius (WhatsAppService)
        ├─── Driver: 'openwa'
        │       │  HTTP POST to OpenWA session endpoint
        │       │  Payload: { phone: '923001234567', body: 'message text' }
        │       ▼
        │    OpenWA Session (WhatsApp Web automation, local or remote)
        │       │  Sends message via WhatsApp Web protocol
        │       ▼
        │    WhatsApp servers → Customer's WhatsApp
        └─── Driver: 'log'
                │  Message written to whatsapp_logs table only
                │  No actual transmission
             whatsapp_logs (for audit / development)

Message flow:
1. Event occurs (invoice generated / payment recorded / customer expiring)
2. Job dispatched: SendWhatsAppJob (or inline WhatsAppService::sendToCustomer)
3. Job picked up by Supervisor queue worker
4. WhatsAppService resolves driver from config
5. Template fetched from whatsapp_templates, variables substituted
6. Message sent via OpenWA HTTP call
7. Result logged to whatsapp_logs (status: sent / failed)

Component Dependency Map

Pages (React)
    └── uses → Components (resources/js/Components/)
    └── calls → Inertia router (navigation, form submit)
    └── reads → $page.props (shared from HandleInertiaRequests middleware)

Controllers
    └── uses → Services (BillingService, RadiusService, etc.)
    └── uses → Models (Eloquent)
    └── dispatches → Jobs (queue)
    └── returns → Inertia::render() or redirect()

Services
    └── uses → Models
    └── uses → External APIs (RouterOS, OpenWA, GenieACS)
    └── calls → AuditLogger (for audit trail)
    └── dispatches → Events / Listeners (if applicable)

Jobs
    └── uses → Services (same services as controllers)
    └── retries on failure → failed_jobs table

Commands (Artisan)
    └── uses → Services
    └── uses → Models
    └── scheduled by → CronJob model (DB-driven scheduler)

Models
    └── scoped by → dealer_id / sub_dealer_id (dealer isolation)
    └── cast → JSONB (permissions), encrypted (api_password)
    └── observed by → Observers (AuditLog generation on model events)

Data Flow Diagrams

1. Customer Login (Admin UI)

Browser: POST /login {email, password}
    → Nginx → PHP-FPM → Laravel
    → Auth\LoginController
    → Laravel Auth::attempt()
        → users table: find by email, verify bcrypt password
    → On success:
        → Session created, stored in Redis
        → Session cookie set in browser
        → Inertia redirect to /dashboard
    → On failure:
        → Redirect back with validation errors
        → Rate limiter incremented (too many attempts → lockout)

Browser: GET /dashboard (with session cookie)
    → HandleInertiaRequests middleware:
        → Loads auth user from session (Redis → users table)
        → Shares: auth.user, auth.permissions, app.settings, flash
    → DashboardController@index
        → Queries stats (scoped by dealer_id if dealer role)
        → Returns Inertia::render('Dashboard', $stats)
    → Browser: React renders Dashboard component with $props

2. Payment Processing

Browser: POST /payments {customer_id, amount, payment_method_id, note}
    → Auth + permission check (billing.payments)
    → PaymentController@store
        → BillingService::recordPayment($customer, $amount, $method, $note)
            → DB::transaction():
                → Insert into payments table
                → Apply payment to oldest unpaid invoices (update invoice.paid_amount)
                → Insert LedgerEntry (debit/credit)
                → Update customer.balance_cache (if used)
            → AuditLogger::log('payment.created', ...)
            → Dispatch: SendWhatsAppJob (payment receipt notification)
    → Redirect back with flash: 'Payment recorded successfully'
    → Queue worker picks up SendWhatsAppJob:
        → WhatsAppService::sendToCustomer($customer, 'payment_receipt', $vars)
        → OpenWA HTTP call → WhatsApp message delivered
        → whatsapp_logs entry written

3. RADIUS Authentication (PPPoE Connect)

Customer powers on router/CPE
    → PPPoE Discovery on MikroTik NAS interface
    → MikroTik sends RADIUS Access-Request to FreeRADIUS:
        User-Name: customer_pppoe_username
        User-Password: (encrypted with shared secret)
        NAS-IP-Address: MikroTik IP
        NAS-Port: PPPoE interface index

FreeRADIUS (pgsql module):
    → SELECT value FROM radcheck
        WHERE username = 'customer_pppoe_username'
        AND attribute = 'Cleartext-Password'
    → Verify password match
    → SELECT groupname FROM radusergroup
        WHERE username = 'customer_pppoe_username'
    → SELECT attribute, op, value FROM radgroupreply
        WHERE groupname = 'package_radius_group'
    → Build Access-Accept with:
        Mikrotik-Rate-Limit: Xk/Yk (from package)
        Session-Timeout: (if set)
        Framed-IP-Address: (for static IP customers)

MikroTik NAS receives Access-Accept:
    → Assigns IP from PPPoE pool (or static IP)
    → Sends Accounting-Start to FreeRADIUS
    → FreeRADIUS inserts row into radacct

PyroRadius (online count, background):
    → KickExpiredOnline command checks customers.expiry_date < now()
    → For expired customers still in radacct (online):
        → MikroTikService: send Disconnect-Message via RouterOS API
        → FreeRADIUS receives CoA / MikroTik kills session
        → FreeRADIUS writes Accounting-Stop to radacct

For database schema details, see 03_Database_Architecture.md. For role and permission details, see 04_User_Roles.md and 05_Authorization_Model.md.