a peculiar thing happens when you spend one semester writing php and the next semester breaking it. you stop trusting most of the code you wrote. you also start trusting a small number of patterns with a kind of conviction that you can't get from reading about them.
this is that small number of patterns.
1. prepared statements, always
you already know this. everyone knows this. the reason it leads the list anyway is that i have personally watched applications fall over because someone interpolated a "trusted" variable into a query string. the variable was trusted right up until it wasn't.
// what broke in the pentest target
$query = "SELECT * FROM movies WHERE id = " . $_GET['movie'];
// what never does
$stmt = $conexion->prepare("SELECT * FROM movies WHERE id = ?");
$stmt->bind_param('i', $_GET['movie']);
$stmt->execute();addslashes() is not parameterisation. regex filters are not parameterisation. "we sanitise before saving" is not parameterisation. the only thing that is parameterisation is parameterisation.
in my own final project, every query that touched user input went through a prepared statement. no exceptions, no "small queries", no admin-only routes left out. the pentest experience is what made that a rule rather than a guideline.
2. password_hash() with the default algorithm
// storing
$hash = password_hash($raw, PASSWORD_DEFAULT);
// verifying
if (password_verify($input, $stored_hash)) { /* ok */ }PASSWORD_DEFAULT is currently bcrypt, will move to whatever php's maintainers decide is the new best practice without breaking existing hashes. that's the design — you write PASSWORD_DEFAULT once and inherit the upgrade path for free.
the thing that drove this home was cracking SHA-1 hashes out of a pentest target with john the ripper. the first password came back in seconds. SHA-1 is fast — that's exactly what you don't want in a password hash. bcrypt is slow on purpose.
3. session_regenerate_id(true) before writing the session
// on successful login
session_regenerate_id(true);
$_SESSION['idUser'] = $user_id;
$_SESSION['rol'] = $role;
$_SESSION['nombre'] = $name;this prevents session fixation. the order matters: regenerate first, then write. if you write the session variables before regenerating, the old session ID still maps to an authenticated session for one request — small window, real bug.
true tells php to delete the old session data instead of leaving it around.
4. one source of truth for validation
this is the one i had to learn the hard way. in an early version of my registration form i checked email uniqueness with a function on the server, and re-implemented the check in a separate ajax endpoint. two implementations, one drifted, results disagreed at the worst possible moment.
the fix is dull and effective: the ajax endpoint calls the same function as the form submit.
// ajax/check-email.php
require_once '../includes/functions.php';
$exists = existe_email($_GET['email']);
echo json_encode(['available' => !$exists]);// registro.php (on submit)
if (existe_email($email)) { /* show error */ }when the rule changes — and rules always change — there's exactly one place to change it.
5. htmlspecialchars() with the right flags
output escaping is where stored xss lives or dies. the default htmlspecialchars($value) doesn't escape single quotes, which means a value rendered inside a single-quoted attribute can break out.
// wrong (in attribute contexts)
<input value='<?= htmlspecialchars($value) ?>'>
// right (everywhere)
<input value='<?= htmlspecialchars($value, ENT_QUOTES, 'UTF-8') ?>'>ENT_QUOTES escapes both single and double quotes. 'UTF-8' makes the function predictable on multi-byte input. it's three extra characters per escape call; make it the default in your project and move on.
6. database transactions around multi-step writes
$conexion->begin_transaction();
try {
$stmt1->execute(); // insert into users_data
$stmt2->execute(); // insert into users_login
$conexion->commit();
} catch (Exception $e) {
$conexion->rollback();
throw $e;
}the trigger for this rule was my registration flow, which writes to two tables. without the transaction, a failure on the second insert leaves a half-registered user — a row in users_data with no matching users_login entry, which is the kind of orphan that haunts a database for years.
the same rule applies to any operation that touches more than one table. if the operation is "all or nothing", make it actually all or nothing.
7. role-based access via a helper, not an if
function require_role($roles, string $redirect = 'login.php'): void {
if (!isset($_SESSION['rol']) || !in_array($_SESSION['rol'], (array) $roles, true)) {
header("Location: $redirect");
exit;
}
}
// top of every admin page
require_role('admin');three reasons this beats inline if (!isset($_SESSION['rol']) || $_SESSION['rol'] !== 'admin'):
- one implementation means one place to change the redirect, the role check, or the exit behaviour.
- the function name documents intent —
require_role('admin')reads as a contract. - the strict comparison (
trueas the third argument toin_array) prevents the loose-comparison surprises php is famous for.
what didn't survive
not every pattern i learned in coursework made the cut. a non-exhaustive list of things i dropped:
- custom md5/sha-1 hashing wrappers. no reason to ever write these.
mysql_*functions. deprecated for years; gone since php 7. alwaysmysqlior pdo.- trusting
$_SERVER['HTTP_REFERER']for anything. spoofable client-side header. - hand-rolled csrf protection without a per-session secret. use a proper token tied to the session, not a hash of the URL.
the underlying lesson: php gets a worse reputation than it deserves, but earning the better reputation means refusing to write the patterns that gave it the worse one. every shortcut listed above is a CVE waiting to be written.