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¶
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:
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 actionaction: dot-notation action name (e.g.customer.recharged)auditable_type+auditable_id: the affected model (polymorphic)old_values: JSON of values before the changenew_values: JSON of values after the changeip_address: from the requestuser_agent: browser/app identifiercreated_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/loginwith an Inertia-compatible response. - For
AuthorizationException(403): render theErrors/403Inertia page. - For
ModelNotFoundException(404): renderErrors/404Inertia page. - For validation errors: Inertia handles these automatically (422 →
errorsinuseForm). - For unhandled exceptions in production: render
Errors/500Inertia page with a reference code.
In development (APP_ENV=local), exceptions surface normally in Ignition.