Engineering • Build notes
Why I built Salix Monitor 360 on LAMP without a PHP framework
Written by Graeme Moignard
Published:
Last updated:
At a glance
A quick orientation before the deeper read.
Salix Monitor 360 is a big build. Monitoring, alerting, dashboards, reporting, payments, tenancy, permissions, integrations, plus the boring-but-critical bits like logging, backups, hygiene and security posture. When you are building a platform like that on your own, your biggest enemy isn’t PHP… it’s time.
I chose a straight LAMP approach (Linux, Apache, MySQL, PHP) without a heavyweight PHP framework. Not because frameworks are “bad”, but because for this product, at this stage, the fastest path to a stable, secure, shippable system is a small, deliberate codebase I control end-to-end.
The real trade-off: shipping product vs shipping architecture
I’m building a commercial platform, not a demo. The priority is getting real features working reliably: monitors, checks, alert routing, incident workflow, role-based access, organisation boundaries, account management, PDF exports, reporting, scheduled jobs, audits and all the operational glue you only discover once it’s running.
A framework gives you a lot out of the box. It also gives you a lot to carry: upgrades, breaking changes, dependency churn, opinionated structure, extra layers and the temptation to “do it the framework way” even when the framework way is slower for the problem you are actually solving.
Why LAMP makes sense for Salix Monitor 360
- Fewer moving parts. Less surface area, fewer surprises, easier fault-finding.
- Performance you can reason about. No hidden ORM behaviour, no accidental N+1 queries, no surprise middleware stacks.
- Security is clearer. I can audit my own entry points instead of inheriting a huge default feature set.
- Upgrades are simpler. PHP version upgrades are enough; I’m not pinned to a framework’s release train.
- Operational fit. Apache, cron and PHP-FPM patterns are boring (good) and widely understood.
The trick is not “raw PHP everywhere”. It’s structured PHP: consistent patterns, reusable helpers, a small routing layer and disciplined boundaries.
Frameworks aren’t the enemy, overhead is
I’m not anti-framework. If you’ve got a team, a tight deadline and you are mostly assembling a standard CRUD app, a framework can be an obvious win. But Salix Monitor 360 isn’t just CRUD.
It’s a monitoring and operations platform. A lot of work lives in:
- network checks and timing logic
- alert routing and escalation
- event ingestion and reduction
- multi-tenant scoping
- report generation and exports
- customer account flows and provider quirks
Framework conventions don’t solve those. They mostly affect how you wire pages together. So I built the wiring I actually needed and kept it lean.
“No framework” doesn’t mean “no structure”
The biggest criticism of rolling your own is that it can become a messy pile. That’s fair , if you don’t impose structure. The approach I use is: keep the architecture small, explicit and repeatable.
A simple pattern that scales
- Front controller (one entry point)
- Router (maps URL → handler)
- Guards (login + permissions)
- Services (storage, account flows, exports, etc.)
- Templates (includes/layout partials)
Code example: one entry point (front controller)
Frameworks usually route everything through a single entry point. You can do the same in LAMP with one rewrite rule.
# .htaccess (concept)
RewriteEngine On
# Allow real files/directories
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# Everything else goes to index.php
RewriteRule ^ index.php [L]
That gives you a clean, predictable “every request comes through one place” model, which is exactly what you want for authentication, tenancy checks and consistent error handling.
Code example: a tiny router (explicit beats magic)
Here’s the sort of routing layer I prefer for a platform build: plain mapping, readable handlers, no surprises.
<?php
// routes.php (concept)
return [
'GET /admin' => ['AdminController', 'dashboard'],
'GET /dashboard' => ['DashboardController', 'index'],
'POST /items' => ['ItemsController', 'create'],
'GET /reports' => ['ReportsController', 'index'],
'POST /events' => ['EventsController', 'store'],
];
<?php
// index.php (concept)
$routes = require __DIR__ . '/routes.php';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$key = $method . ' ' . rtrim($path, '/');
if ($key === 'GET') $key = 'GET /';
if (!isset($routes[$key])) {
http_response_code(404);
require __DIR__ . '/views/errors/404.php';
exit;
}
[$class, $action] = $routes[$key];
require __DIR__ . '/bootstrap.php';
$controller = new $class($container);
$controller->$action();
You can make this more advanced (route params, groups, middleware), but the point is that you only add complexity when the product demands it , not because a framework shipped it.
Code example: database access without ORM pain
ORMs are handy until they aren’t. For monitoring and account-heavy systems, I want the important data paths to stay explicit and understandable. PDO with prepared statements is boring, fast and safe.
<?php
// db.php (concept)
function db(): PDO {
static $pdo;
if ($pdo instanceof PDO) return $pdo;
$pdo = new PDO(
'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4',
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
return $pdo;
}
<?php
// Example query (concept)
$stmt = db()->prepare('
SELECT id, name, status
FROM monitors
WHERE organisation_id = :org
ORDER BY created_at DESC
LIMIT 200
');
$stmt->execute([':org' => $orgId]);
$rows = $stmt->fetchAll();
No magic, no hidden queries, no guessing what the ORM is doing. When performance matters (and it does in monitoring), this clarity pays back constantly.
Security example: tenancy and permission guards
Multi-tenant code lives or dies by discipline. A framework doesn’t automatically save you from accidental data leaks. The best defence is consistent guard code that you call everywhere.
<?php
// guard.php (concept)
function require_login(): void {
if (empty($_SESSION['user_id'])) {
header('Location: /login');
exit;
}
}
function require_org_scope(int $orgId): void {
// Enforce tenant boundary
if ((int)($_SESSION['org_id'] ?? 0) !== (int)$orgId) {
http_response_code(403);
exit('Forbidden');
}
}
function require_permission(string $perm): void {
$perms = $_SESSION['perms'] ?? [];
if (!in_array($perm, $perms, true)) {
http_response_code(403);
exit('Forbidden');
}
}
The point: I want the “rules of the world” to be obvious, testable and hard to bypass by accident. Simple functions, called everywhere, win.
Templating example: includes are fine when you keep them disciplined
You don’t need a templating engine to get reuse and consistency. For a SaaS admin UI, includes and partials are often enough: one layout, shared navigation, consistent escaping and small view files.
<?php
// render.php (concept)
function e(string $s): string {
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function render(string $view, array $data = []): void {
extract($data, EXTR_SKIP);
require __DIR__ . '/views/partials/header.php';
require __DIR__ . '/views/' . $view . '.php';
require __DIR__ . '/views/partials/footer.php';
}
That’s enough structure to avoid copy/paste soup, while keeping the “how it works” completely transparent.
Jobs, schedules and monitoring logic
Monitoring platforms always need background jobs: checks, retries, notifications, aggregation, cleanup, exports. In LAMP land, cron is still a solid tool: predictable, easy to check and easy to run on any Linux box.
# Cron (concept)
* * * * * /usr/bin/php /var/www/sm360/bin/run_checks.php >> /var/log/sm360/checks.log 2>&1
*/5 * * * * /usr/bin/php /var/www/sm360/bin/process_alerts.php >> /var/log/sm360/alerts.log 2>&1
0 2 * * * /usr/bin/php /var/www/sm360/bin/cleanup.php >> /var/log/sm360/cleanup.log 2>&1
A framework job queue can be brilliant. But you also inherit queue infrastructure, supervisors and deployment complexity. For early and mid-stage product build, cron plus clean scripts is often the fastest route to “it runs every time”.
“But frameworks give you best practices”
They can. They also give you defaults that may not match your threat model, your tenancy model, or your performance goals. Best practice isn’t something you install; it’s what you enforce.
The best practices I care about in Salix Monitor 360 are simple and relentless:
- Prepared statements everywhere (no string-built SQL).
- Consistent permission checks at every boundary.
- Consistent output escaping in templates.
- Clear logging so production issues aren’t guesswork.
- Small, auditable dependencies and upgrades I can control.
Where a framework would be a good fit (and why I still didn’t)
If Salix Monitor 360 was a content site, a simple CRM, or a basic admin panel with a couple of models, I’d likely pick a framework and move on. The issue isn’t capability , it’s focus.
I’m building a platform where the complexity is in the operational domain, not in the routing layer. So I kept the routing layer thin and predictable and put my time into what customers actually pay for: monitoring that’s reliable, alerts that are actionable and a system that behaves.
Final thoughts
The “right” stack is the one that helps you ship a reliable product without painting yourself into a corner. For Salix Monitor 360, LAMP without a heavyweight PHP framework is the right move: it’s fast to build, easy to operate, and it keeps the system understandable.
As the platform grows, I can always add structure where it genuinely earns its keep , but I’m not starting with a tower of abstractions just to feel enterprise.
If you are building something large and real, don’t let anyone shame you for choosing boring technology. Boring tech is often the reason the product actually ships.