Skip to content

08 — Backend Architecture

System: PyroRadius ISP Billing & RADIUS Management
Stack: Laravel 13 · PHP 8.3 · PostgreSQL 16 · Redis · FreeRADIUS
Last Updated: 2026-07-13


Overview

PyroRadius is a Laravel 13 monolith using a layered service architecture. Controllers stay thin — they validate input and delegate to service classes which contain all business logic. Models are responsible for data access and relationships only. This separation keeps individual files small, makes the business rules testable in isolation, and prevents the controller bloat common in Laravel applications.


Directory Structure

app/
├── Console/
│   ├── Commands/                  # All artisan commands
│   │   ├── CheckExpiry.php        # Marks expired customers, removes from RADIUS
│   │   ├── GenerateMonthlyInvoices.php
│   │   ├── SyncAllRadius.php      # Bulk RADIUS sync for all active customers
│   │   ├── CloseGhostSessions.php # Closes radacct rows with no Stop record
│   │   ├── KickExpiredOnline.php  # Disconnects expired customers still online
│   │   ├── HealOverpaidInvoices.php
│   │   └── RunScheduledCronJobs.php # Reads cron_jobs table, runs due jobs
│   └── Kernel.php                 # Scheduler configuration
├── Events/
│   ├── CustomerRecharged.php
│   ├── CustomerExpired.php
│   ├── RadiusSyncFailed.php
│   └── InvoiceGenerated.php
├── Exceptions/
│   └── Handler.php                # Global exception handler, formats Inertia errors
├── Http/
│   ├── Controllers/               # Thin controllers — validate + delegate
│   │   ├── Api/                   # AJAX-only controllers (return JSON)
│   │   ├── Auth/
│   │   ├── CustomerController.php
│   │   ├── InvoiceController.php
│   │   ├── PaymentController.php
│   │   ├── PackageController.php
│   │   ├── NasController.php
│   │   ├── DashboardController.php
│   │   └── ...
│   ├── Middleware/
│   │   ├── HandleInertiaRequests.php   # Shares auth/flash/company props
│   │   ├── Authenticate.php
│   │   └── CheckPermission.php
│   └── Requests/                  # Form Request validation classes
│       ├── StoreCustomerRequest.php
│       ├── UpdateCustomerRequest.php
│       ├── RechargeRequest.php
│       ├── StorePackageRequest.php
│       └── ...
├── Jobs/                          # Queue jobs
│   ├── ProcessCustomerImport.php
│   ├── SendWhatsAppMessage.php
│   ├── GeneratePdfInvoice.php
│   ├── SyncRadiusJob.php
│   └── DispatchCampaign.php
├── Listeners/
│   ├── SendPaymentWhatsApp.php    # Listens to CustomerRecharged
│   ├── LogCustomerExpiry.php
│   └── NotifyAdminOnRadiusFailure.php
├── Models/
│   ├── Customer.php
│   ├── Invoice.php
│   ├── Payment.php
│   ├── LedgerEntry.php
│   ├── Package.php
│   ├── Nas.php
│   ├── Olt.php
│   ├── User.php
│   ├── Role.php
│   ├── AuditLog.php
│   ├── CronJob.php
│   ├── WhatsAppMessage.php
│   └── ...
├── Observers/
│   ├── CustomerObserver.php
│   ├── PackageObserver.php
│   └── InvoiceObserver.php
└── Services/
    ├── BillingService.php         # Core billing logic
    ├── RadiusService.php          # FreeRADIUS table writes
    ├── WhatsAppService.php        # WhatsApp API integration
    ├── MikroTikService.php        # RouterOS API calls
    ├── AuditLogger.php            # Structured audit logging
    ├── BillingArchiveService.php  # Month-end archive process
    └── ImportService.php          # CSV import processing

Service Layer Pattern

Controller — Validate and Delegate

Controllers do exactly two things: validate input and call a service method. They do not contain SQL queries, business logic, or RADIUS code.

// app/Http/Controllers/CustomerController.php

class CustomerController extends Controller
{
    public function __construct(
        private readonly BillingService $billing,
        private readonly RadiusService  $radius,
        private readonly WhatsAppService $whatsapp,
        private readonly AuditLogger    $audit,
    ) {}

    public function recharge(RechargeRequest $request, Customer $customer): Response
    {
        // RechargeRequest already validated: amount, method, notes, invoice_id
        $payment = $this->billing->recordPayment(
            customer: $customer,
            amount:   $request->validated('amount'),
            method:   $request->validated('method'),
            notes:    $request->validated('notes'),
            invoiceId: $request->validated('invoice_id'),
            recordedBy: auth()->user(),
        );

        $this->audit->log('customer.recharged', $customer, [
            'payment_id' => $payment->id,
            'amount'     => $payment->amount,
        ]);

        return Inertia::back()->with('success', 'Payment recorded successfully.');
    }
}

Service — Business Logic

Services are plain PHP classes resolved from the container. They can call other services, fire events, and interact with models. They never interact with HTTP (no request(), redirect(), or response() calls).

// app/Services/BillingService.php

class BillingService
{
    public function __construct(
        private readonly RadiusService $radius,
        private readonly WhatsAppService $whatsapp,
    ) {}

    public function recordPayment(
        Customer $customer,
        float    $amount,
        string   $method,
        ?string  $notes,
        ?int     $invoiceId,
        User     $recordedBy,
    ): Payment {
        return DB::transaction(function () use ($customer, $amount, $method, $notes, $invoiceId, $recordedBy) {
            // Create payment record, ledger entries, update invoice, renew if paid
            // ... (full implementation documented in 10_Billing_System.md)
        });
    }
}

Model — Data and Relationships

Models define Eloquent relationships, casts, scopes, and fillable fields. No business logic lives in models.

// app/Models/Customer.php

class Customer extends Model
{
    protected $casts = [
        'expiry_date' => 'date',
        'is_active'   => 'boolean',
    ];

    public function package(): BelongsTo      { return $this->belongsTo(Package::class); }
    public function invoices(): HasMany        { return $this->hasMany(Invoice::class); }
    public function payments(): HasMany        { return $this->hasMany(Payment::class); }
    public function ledgerEntries(): HasMany   { return $this->hasMany(LedgerEntry::class); }
    public function dealer(): BelongsTo       { return $this->belongsTo(User::class, 'dealer_id'); }
    public function nas(): BelongsTo          { return $this->belongsTo(Nas::class); }

    // Scopes
    public function scopeActive($q)    { return $q->where('status', 'active'); }
    public function scopeExpired($q)   { return $q->where('status', 'expired'); }
    public function scopeExpiring($q, int $days = 3) {
        return $q->whereBetween('expiry_date', [now(), now()->addDays($days)]);
    }
}

Dependency Injection

Laravel's service container is used throughout. Services are bound in AppServiceProvider or resolved automatically by the container via type-hinted constructor parameters (autowiring).

Heavy services are registered as singletons to avoid recreating connections on each resolution:

// app/Providers/AppServiceProvider.php

public function register(): void
{
    $this->app->singleton(RadiusService::class, function ($app) {
        return new RadiusService(
            db: DB::connection('radius')  // dedicated RADIUS DB connection
        );
    });

    $this->app->singleton(MikroTikService::class, function ($app) {
        return new MikroTikService(
            timeout: config('mikrotik.api_timeout', 5)
        );
    });
}

Form Request Validation

Every write operation (POST, PUT, DELETE) uses a dedicated FormRequest class. This keeps validation rules out of controllers and makes them testable.

// app/Http/Requests/RechargeRequest.php

class RechargeRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('recharge_customers');
    }

    public function rules(): array
    {
        return [
            'amount'     => ['required', 'numeric', 'min:1', 'max:999999'],
            'method'     => ['required', 'in:cash,bank_transfer,easypaisa,jazzcash,card'],
            'notes'      => ['nullable', 'string', 'max:500'],
            'invoice_id' => ['nullable', 'integer', 'exists:invoices,id'],
        ];
    }

    public function messages(): array
    {
        return [
            'amount.min'  => 'Payment amount must be at least Rs. 1.',
            'method.in'   => 'Invalid payment method selected.',
        ];
    }
}

When validation fails, Laravel automatically returns a 422 response. Inertia catches this and populates the errors object in useForm, displaying field-level errors in the React component.


Middleware Stack

The full middleware stack for a typical authenticated web request:

1. TrustProxies             — Handles X-Forwarded-For from load balancer
2. HandleCors               — CORS headers (only relevant for API routes)
3. PreventRequestsDuringMaintenance — 503 if maintenance mode on
4. ValidatePostSize         — Rejects oversized POST bodies
5. TrimStrings              — Trims whitespace from all string inputs
6. ConvertEmptyStringsToNull — Converts "" to null
7. StartSession             — Starts PHP session
8. ShareErrorsFromSession   — Makes $errors available in all views
9. VerifyCsrfToken          — Validates CSRF token
10. SubstituteBindings      — Resolves route model bindings
11. HandleInertiaRequests   — Shares props for Inertia (auth, flash, etc.)
12. Authenticate            — Checks auth guard, redirects to /login if guest

RADIUS Integration Architecture

PyroRadius does not use the FreeRADIUS REST module or a custom RADIUS module. Instead it uses the tables-as-API pattern: FreeRADIUS is configured to read from and write to a shared PostgreSQL database. PyroRadius writes to those tables; FreeRADIUS reads them at authentication time.

PPPoE Client
FreeRADIUS
    │  Reads at auth time:
    ├──▶ radcheck      (username + password)
    ├──▶ radusergroup  (maps username → group name)
    └──▶ radgroupreply (group name → speed attributes)
    │  Writes accounting data:
    └──▶ radacct       (session start/stop/update)

PyroRadius (Laravel)
    │  Writes at provisioning time:
    ├──▶ radcheck      (RadiusService::syncCustomer)
    ├──▶ radusergroup  (RadiusService::syncCustomer)
    └──▶ radgroupreply (RadiusService::syncPackage)

FreeRADIUS has no knowledge of invoices, customers, or billing logic. It only knows: "is this username in radcheck with a valid password?" and "what group is this username in?" PyroRadius controls access by adding/removing rows from these tables.


Queue Jobs Architecture

All time-consuming or non-critical operations are dispatched to the queue:

Job Class Queue Trigger Purpose
SendWhatsAppMessage whatsapp After payment, expiry HTTP call to WhatsApp API
ProcessCustomerImport imports CSV upload Row-by-row customer creation
GeneratePdfInvoice pdf Invoice created Renders PDF to storage
SyncRadiusJob radius Package change Calls RadiusService::syncCustomer()
DispatchCampaign campaigns Campaign store Fans out per-customer messages
GenerateMonthlyInvoicesJob billing Scheduled Creates invoices for all active customers

Queue backend: Redis. Workers are configured via Supervisor.

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


Artisan Commands and Scheduler

DB-Driven Cron System

Rather than hard-coding all scheduled tasks in Console/Kernel.php, PyroRadius uses a cron_jobs table. The RunScheduledCronJobs command runs every minute, reads the table, and dispatches due jobs. This allows admins to enable/disable and reschedule commands from the Settings UI without a deployment.

CREATE TABLE cron_jobs (
    id              SERIAL PRIMARY KEY,
    name            VARCHAR(100) NOT NULL,
    command         VARCHAR(255) NOT NULL,  -- artisan command name
    schedule        VARCHAR(100) NOT NULL,  -- cron expression
    is_enabled      BOOLEAN DEFAULT TRUE,
    last_run_at     TIMESTAMPTZ,
    next_run_at     TIMESTAMPTZ,
    created_at      TIMESTAMPTZ,
    updated_at      TIMESTAMPTZ
);

Core Scheduled Commands

Command Default Schedule Purpose
radius:check-expiry Every hour Finds expired customers, removes from RADIUS, updates status
billing:generate-monthly 1st of month, 02:00 Generates invoices for all packages due this month
radius:sync-all Daily, 03:00 Full RADIUS table reconciliation
radius:close-ghost-sessions Every 6 hours Closes radacct rows open > 24h with no Stop
radius:kick-expired Every 30 min Disconnects online customers whose account has expired
billing:heal-overpaid Daily, 04:00 Fixes invoices where paid > total due rounding
billing:archive Last day of month, 23:30 Runs the month-end archive process

Observer Pattern

Eloquent observers allow cross-cutting concerns (audit logging, cache invalidation) without cluttering models or services.

CustomerObserver

// app/Observers/CustomerObserver.php

class CustomerObserver
{
    public function created(Customer $customer): void
    {
        AuditLog::write('customer.created', $customer);
        cache()->forget('dashboard.customer_count');
    }

    public function updated(Customer $customer): void
    {
        if ($customer->isDirty('status')) {
            AuditLog::write('customer.status_changed', $customer, [
                'from' => $customer->getOriginal('status'),
                'to'   => $customer->status,
            ]);
        }
        if ($customer->isDirty('package_id')) {
            // Package changed — queue a RADIUS resync
            SyncRadiusJob::dispatch($customer)->onQueue('radius');
        }
    }

    public function deleted(Customer $customer): void
    {
        AuditLog::write('customer.deleted', $customer);
        // RADIUS cleanup is handled by controller/service before soft-delete
    }
}

PackageObserver

class PackageObserver
{
    public function updated(Package $package): void
    {
        if ($package->isDirty(['download_speed', 'upload_speed', 'group_name'])) {
            // Speed changed — sync all customers on this package
            RadiusService::syncPackage($package);
        }
    }
}

InvoiceObserver

Handles cache busting for revenue dashboard widgets when an invoice's status changes.


Event / Listener System

Event Fired By Listeners
CustomerRecharged BillingService::recordPayment() SendPaymentWhatsApp, LogRechargeAudit
CustomerExpired CheckExpiry command LogCustomerExpiry, SendExpiryWhatsApp
RadiusSyncFailed RadiusService::syncCustomer() NotifyAdminOnRadiusFailure
InvoiceGenerated BillingService::generateInvoice() GeneratePdfInvoice (dispatches job)

Events are queued listeners — they are dispatched to the queue rather than running synchronously — except LogRechargeAudit which is synchronous to ensure the audit record is written before the response returns.


PostgreSQL-Specific Optimisations

PyroRadius takes deliberate advantage of PostgreSQL features not available in MySQL/SQLite:

Functional Indexes

-- Case-insensitive username lookup (RADIUS auth is case-sensitive but admin search is not)
CREATE INDEX idx_customers_username_lower ON customers (LOWER(username));

-- Index on expiry_date for the CheckExpiry command
CREATE INDEX idx_customers_expiry_active ON customers (expiry_date)
    WHERE status = 'active';

PostgreSQL Sequences for Invoice/Receipt Numbers

CREATE SEQUENCE seq_invoice_no START 1;
CREATE SEQUENCE seq_receipt_no START 1;

Laravel calls nextval('seq_invoice_no') directly to get the next number atomically — no locking needed, no gaps from failed transactions polluting the sequence.

$number = DB::selectOne("SELECT nextval('seq_invoice_no') AS n")->n;
$invoiceNo = 'INV' . now()->format('Ym') . '-' . str_pad($number, 5, '0', STR_PAD_LEFT);
// → INV202506-00001

lock_timeout to Prevent Deadlock Hangs

In recordPayment(), before acquiring row locks:

DB::statement("SET LOCAL lock_timeout = '3s'");

If a lock cannot be acquired in 3 seconds, PostgreSQL throws a LockNotAvailable exception rather than waiting indefinitely. The caller catches this and retries.

IS DISTINCT FROM for Nullable Comparisons

When checking whether a value actually changed (including NULL → value or value → NULL transitions):

// Eloquent's isDirty() handles this, but raw queries use IS DISTINCT FROM:
$sql = "UPDATE radgroupreply SET value = ? WHERE groupname = ? AND attribute = ?
        AND value IS DISTINCT FROM ?";

SKIP LOCKED for Queue-Like Processing

The GenerateMonthlyInvoices command uses SKIP LOCKED to allow multiple workers to process customer batches without collision:

SELECT id FROM customers
WHERE status = 'active' AND generate_invoice = true
FOR UPDATE SKIP LOCKED
LIMIT 100;

Redis Usage

Purpose Key Pattern TTL
Online customer count dashboard.online_count 30 seconds
Company profile cache company_profile 5 minutes
Dashboard stat widgets dashboard.stats.* 2 minutes
Package availability package.{id}.active_count 1 minute
Queue backend (managed by Laravel queue) N/A
Session storage laravel_session.* Per session config

Redis is configured as both the cache store and the queue backend. This means if Redis is unavailable, both caching and queue processing stop — a redis-sentinel or at minimum redis-cli ping monitoring alert should be in place.

The Cache::remember() pattern is used consistently:

$onlineCount = Cache::remember('dashboard.online_count', 30, function () {
    return DB::connection('radius')
        ->table('radacct')
        ->whereNull('acctstoptime')
        ->count();
});

AuditLogger Service

Every significant action is recorded in the audit_logs table via AuditLogger::log(). The logger captures:

  • user_id: who performed the action
  • action: dot-notation action name (e.g. customer.recharged)
  • auditable_type + auditable_id: the affected model (polymorphic)
  • old_values: JSON of values before the change
  • new_values: JSON of values after the change
  • ip_address: from the request
  • user_agent: browser/app identifier
  • created_at: timestamp
// app/Services/AuditLogger.php

class AuditLogger
{
    public function log(string $action, Model $model, array $meta = []): AuditLog
    {
        return AuditLog::create([
            'user_id'        => auth()->id(),
            'action'         => $action,
            'auditable_type' => get_class($model),
            'auditable_id'   => $model->getKey(),
            'meta'           => $meta,
            'ip_address'     => request()->ip(),
            'user_agent'     => request()->userAgent(),
        ]);
    }
}

The AuditController exposes the log in the UI with filtering by user, action, date range, and auditable model. Admins can export audit logs to CSV.


Error Handling and Inertia

app/Exceptions/Handler.php is extended to format errors for Inertia responses correctly:

  • For AuthenticationException: redirect to /login with an Inertia-compatible response.
  • For AuthorizationException (403): render the Errors/403 Inertia page.
  • For ModelNotFoundException (404): render Errors/404 Inertia page.
  • For validation errors: Inertia handles these automatically (422 → errors in useForm).
  • For unhandled exceptions in production: render Errors/500 Inertia page with a reference code.

In development (APP_ENV=local), exceptions surface normally in Ignition.