PHP still powers a huge share of the commercial web — not because it’s fashionable, but because it’s available on almost every host, fast enough for business sites, and “boring” in the good way. The difference between a portal that ages well and one that becomes a rewrite project is rarely “Laravel vs plain PHP”. It’s structure, naming, security habits, and how many shortcuts you took on day one.
This guide is written for UK business owners who care about maintainability, and for developers who want code that another human can open in six months without swearing. It’s technical where it needs to be, and plain English everywhere else.
What “maintainable” actually means
It means a future you (or another developer) can open the project and answer three questions in under ten minutes:
- Where does this feature live? (not “somewhere in functions.php and three random includes”)
- What is allowed to touch the database? (one path, not twenty)
- How do I deploy a safe change? (documented, reversible, tested)
If those answers require tribal knowledge on Slack, the code is already expensive — even if it “works” today.
Clever code impresses once. Clear code pays for itself for years.
A simple project layout that scales
You don’t need a 200-class domain model to be professional. You need boundaries. For custom portals, client areas and marketing systems I often use a thin, obvious layout:
public/— web root only:index.php, assets, no secrets, no business logic dumpsincludes/orsrc/— config, auth, data access, pure helperstemplates/— HTML views with almost no business logicsql/ormigrations/— schema changes you can re-run with confidence.envor local config outside the web root — database passwords, API keys, mail credentials
Front controller pattern is enough for many projects: every request enters through one file, routes to a handler, and returns a template. That single entry point is where you put session start, CSRF checks, and authentication gates — not sprinkled randomly across twenty endpoints.
Talk to the database safely (always)
If you only fix one habit in a legacy PHP codebase, make it this: prepared statements for every query. String-built SQL is how SQL injection still shows up in 2026.
// Good: PDO prepared statement
$stmt = $pdo->prepare(
'SELECT id, email, role FROM users WHERE email = ? LIMIT 1'
);
$stmt->execute([$email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
Also worth doing properly:
- Use a single shared PDO connection with exceptions enabled (
ERRMODE_EXCEPTION) - Set a sensible charset (
utf8mb4) so emoji and multi-language content don’t corrupt - Never log raw passwords or full card data — ever
- Prefer transactions when you write multiple related rows (orders + line items)
Security is not optional boilerplate
Small business sites get compromised more often from hygiene failures than Hollywood zero-days. Bake these in from the first commit:
- Passwords:
password_hash()/password_verify()only — never MD5 or home-rolled crypto - Sessions: regenerate ID on login; store session data server-side; use HTTPS cookies (
Secure,HttpOnly,SameSite) - CSRF: token on every state-changing form (POST that creates, updates, deletes)
- XSS: escape output by default with
htmlspecialchars(..., ENT_QUOTES, 'UTF-8'); only allow HTML through a strict sanitiser - Uploads: check MIME type, limit size, store outside public if possible, never trust the original filename
- Secrets: environment or local config outside the web root — not hard-coded in git
If you’re handling payments, don’t store card numbers. Use a PCI-aware provider (Stripe, etc.) and treat webhooks as untrusted input until verified.
Composer: use it when it earns its keep
Composer is excellent for mailers, HTTP clients, PDF generation and well-maintained utilities. It’s a liability when you pull half of Packagist for a contact form.
- Prefer a small, audited set of packages
- Pin versions (or use a lock file and deploy it)
- Document why each dependency exists in the README
- Run
composer audit(or equivalent) before major releases
Frameworks (Laravel, Symfony) earn their keep when the product is an application with many domains, queues, jobs and roles. For a focused portal with five screens, a framework can be more ceremony than value — but “no framework” is not an excuse for spaghetti.
Error handling and logging you will actually read
Production should never show stack traces to visitors. It should log them somewhere you check.
- Turn
display_errorsoff in production - Log to a file or service with request ID, user ID (if any), and URL
- Distinguish 404s (expected noise) from 500s (real failures)
- Alert on spike in 500s after deploy — not three days later when a client emails
Configuration by environment
Local, staging and production should differ only by config — not by mystery code branches. Typical pattern:
APP_ENV=local|staging|production- Database DSN, mail driver, and debug flags from environment
- Feature flags for risky work still in progress
If your “staging” is a different PHP version to production, you’re testing the wrong thing.
Deploy without drama
FTP drag-and-drop of half a project is technical debt. Aim for:
- Staging that mirrors production PHP and extensions
- One deploy path (git pull + release folder, or rsync of a built tree)
- Backups before schema changes — and a restore you’ve actually tried
- Migrations as files, not “I ran this once in phpMyAdmin”
- A README that says how to run the project locally in under ten minutes
When WordPress is enough — and when it isn’t
WordPress is excellent for content and many shops. It’s awkward when the product is the software: multi-role workflows, domain-specific data models, invoice states, approval chains. In those cases custom PHP (or a proper app framework) with a clear schema often stays cleaner than forcing everything into post meta.
The honest test: if half your “content” is actually relational business data with permissions, you’re building an application — treat it like one.
A practical checklist before you call it done
- New developer can find auth, DB and templates without a guided tour
- No secrets in the repository
- All user input validated; all output escaped
- Forms protected with CSRF
- Staging deploy tested this month
- Backup restore tested this year
- README exists and is accurate
Worked examples you can adapt
These are shortened, real-world patterns — not framework religion. Copy the idea, then fit it to your project.
1) PDO bootstrap (one connection, exceptions on)
// includes/db.php
function db(): PDO {
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4';
$pdo = new PDO($dsn, 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;
}
2) Safe insert with prepared statements
function create_lead(string $name, string $email, string $message): int {
$stmt = db()->prepare(
'INSERT INTO leads (name, email, message, created_at)
VALUES (?, ?, ?, NOW())'
);
$stmt->execute([
trim($name),
strtolower(trim($email)),
trim($message),
]);
return (int) db()->lastInsertId();
}
3) CSRF token on forms
// start session once at the front controller
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
// in the form
// <input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
// on POST
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(403);
exit('Invalid request token.');
}
4) Escape output (XSS)
function h(?string $s): string {
return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
// template
// <h1><?= h($page['title']) ?></h1>
// <p><?= h($page['lead']) ?></p>
5) Password hash / verify
// register
$hash = password_hash($plainPassword, PASSWORD_DEFAULT);
// login
if (!password_verify($plainPassword, $user['password_hash'])) {
// generic error — don't reveal which field failed
throw new RuntimeException('Invalid login.');
}
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
If you’re building a custom tool, shop admin or client portal and want PHP that won’t fight you next year: book a free call →