PHP Composer Autoload PSR-4 Example (Step by Step)
Wire Composer PSR-4 autoload in PHP: map App\ to src/App/, require vendor/autoload.php, dump-autoload, and fix the usual class-not-found mistakes.
To prevent cross-site scripting (XSS) when you print user data in HTML with PHP, escape it on output with htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'). That turns <, >, &, " and ' into entities so a string like <script>alert(1)</script> becomes harmless text instead of a real script tag. Escape when you echo into HTML, not when you save to the database. Prepared statements stop SQL injection; htmlspecialchars stops HTML injection. They solve different problems.
I wrote this as the output half of my PHP form validation and sanitisation note. Validation decides whether data is allowed in; escaping decides how it is shown. Every snippet below was run on PHP 8.4 on the command line, and the outputs are pasted from that run. If you only remember one habit from this page, make it: every user-controlled string that becomes HTML goes through htmlspecialchars (or a tiny e() helper) at the last moment.
Cross-site scripting means an attacker gets the browser to treat their text as code on your origin. The classic PHP bug is reflecting a query string straight into the page:
<?php
// Vulnerable on purpose — do not ship this
$q = $_GET['q'] ?? '';
echo '<p>You searched for: ' . $q . '</p>';
Visit ?q=<script>alert(1)</script> and the browser runs the script with your cookies and session. Stored XSS is the same idea with a delay: the payload is saved in the database (a comment, a display name) and runs when someone else views the page. Reflected XSS hits the person who opens the crafted link; stored XSS hits every visitor who sees that row.
Neither bug is fixed by “validating that the search is not emptyâ€. Empty checks and length limits are still useful, but the attack string is usually short and looks like normal text until it is parsed as HTML. Escaping on output is the reliable fix for HTML contexts.
<?php
declare(strict_types=1);
$q = $_GET['q'] ?? '<script>alert(1)</script>';
$safe = htmlspecialchars($q, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo $safe, PHP_EOL;
$name = 'O\'Brien "quoted" <b>x</b>';
$attr = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo '<input value="' . $attr . '">', PHP_EOL;
// PHP 8.1+ defaults already include ENT_QUOTES | ENT_SUBSTITUTE
echo htmlspecialchars("a\"b'c<d>&"), PHP_EOL;
$price = '£12.50';
echo htmlspecialchars($price, ENT_QUOTES, 'UTF-8'), PHP_EOL;
Output from PHP 8.4:
<script>alert(1)</script>
<input value="O'Brien "quoted" <b>x</b>">
a"b'c<d>&
£12.50
Reading that output:
<script>... on the page instead of executing it." and ' are encoded, so neither style of quoting lets the value break out.£ when you pass 'UTF-8'. That is one reason I prefer htmlspecialchars over htmlentities on modern UTF-8 sites.Older tutorials often write htmlspecialchars($s) without flags. Before PHP 8.1 the default was ENT_COMPAT, which encodes double quotes but leaves single quotes alone. This markup is then still vulnerable:
<input value='<?= htmlspecialchars($name) ?>'>
An input of x' onfocus='alert(1) can break out. ENT_QUOTES encodes both quote styles. ENT_SUBSTITUTE avoids the nasty case where invalid UTF-8 made older PHP return an empty string and silently blank the field — attackers used encoding tricks historically; substitution is the safer modern default.
Always pass 'UTF-8' when you set flags yourself. If the charset argument is wrong, multibyte characters can be mishandled. Your HTML page should also declare UTF-8 (Content-Type header and/or <meta charset="utf-8">) so the browser agrees with PHP.
e() helper<?php
function e(?string $s): string
{
return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
Usage in a template:
<p>Hello, <?= e($user['name']) ?></p>
<input name="email" value="<?= e($email) ?>">
<textarea name="bio"><?= e($bio) ?></textarea>
Casting null to string keeps strict types happy and prints nothing. Name the helper something short so you actually use it. In larger apps the same idea lives in the template engine (Twig and Blade auto-escape in their default print syntax). In plain PHP you are the template engine, so the helper is on you.
Do not treat e() as “safe for every contextâ€. It is for HTML text and quoted attributes only. JavaScript strings, CSS, and raw URLs need different handling (covered below).
After validation fails you redisplay the form. That is good UX and a common XSS hole, because the value sits inside quotes:
<?php
function e(?string $s): string
{
return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$email = $_POST['email'] ?? 'test" onclick="alert(1)';
echo '<input name="email" value="' . e($email) . '">', PHP_EOL;
Output:
<input name="email" value="test" onclick="alert(1)">
Without e(), the browser would see a closed value and a new onclick attribute. With e(), the whole attack stays inside the value string.
This pairs with:
Sanitising on the way in (trim, length, email format) is still worthwhile. It is not a substitute for escaping on the way out.
A pattern I still see in old WordPress-era snippets:
<?php
// Avoid this pattern
$clean = htmlspecialchars($_POST['title'], ENT_QUOTES, 'UTF-8');
$pdo->prepare('INSERT INTO posts (title) VALUES (?)')->execute([$clean]);
Problems:
& litter.&amp;.Prefer:
<?php
$title = trim($_POST['title'] ?? '');
// validate length / allowed characters as needed
$pdo->prepare('INSERT INTO posts (title) VALUES (?)')->execute([$title]);
// Much later, in the view:
echo '<h2>' . e($post['title']) . '</h2>';
Database safety is PDO prepared statements. HTML safety is htmlspecialchars. Password safety is password_hash. Three tools, three jobs.
<?php
$url = 'javascript:alert(1)';
echo htmlspecialchars($url, ENT_QUOTES, 'UTF-8'), PHP_EOL;
// Still prints: javascript:alert(1)
Put that in href and you may still get script execution. Escape does not validate schemes. Allow only http and https after parse_url, or map an ID to a known path on your site.
<!-- Dangerous pattern -->
<button onclick="greet('<?= e($name) ?>')">Hi</button>
Even with HTML escaping, quote tricks and context confusion bite people here. Prefer:
<button id="greet" data-name="<?= e($name) ?>">Hi</button>
<script>
document.getElementById('greet').addEventListener('click', (e) => {
const name = e.currentTarget.dataset.name;
// use name as data, not as code
});
</script>
Or pass structured data with json_encode into a type="application/json" script tag, then read it from JavaScript.
Do not interpolate user input into style="...". Colour pickers should map to an allow-list of classes or hex values you validate with a regex, not raw strings.
Blog comments with bold/links need an HTML purifier with a strict allow-list of tags and attributes. strip_tags($html, '<b><i><a>') is incomplete (attributes on <a> can still carry javascript:). htmlspecialchars would show the tags as text, which is correct for plain comments but wrong if you intentionally want markup.
ENT_COMPAT / old defaults with single-quoted attributes.strip_tags as your only defence.href, src, or style after only htmlspecialchars.No. It prevents HTML injection into text nodes and properly quoted attributes when used correctly. It does not secure JavaScript contexts, CSS, or unvalidated URLs. Add prepared statements, HttpOnly session cookies, and ideally a Content-Security-Policy.
For UTF-8 sites, prefer htmlspecialchars. It encodes the characters that matter for HTML structure and leaves £ and accented letters alone when charset is UTF-8. htmlentities encodes far more code points and historically mangled UTF-8 when the charset argument was omitted.
Defaults already include ENT_QUOTES | ENT_SUBSTITUTE. Spelling them out documents intent and keeps copy-pasted snippets safe on older hosts. If you pass flags yourself, also pass 'UTF-8'.
No for HTML escaping. Save the real text (after validation). Escape when rendering HTML. Use prepared statements for the query itself.
It overlaps, but the filter extension’s sanitisation filters have been deprecated or changed across versions and hide the escaping contract. For HTML output, call htmlspecialchars or e() so anyone reading the template sees the defence.
If you see &lt; in the page source, something escaped twice. Usually a layer escaped on save and the view escaped again. Store raw, escape once on output. If you must display entities as examples, escape the already-escaped string deliberately and say so.
Putting the pieces together, here is a single-file pattern you can run with php -S localhost:8080:
<?php
declare(strict_types=1);
function e(?string $s): string
{
return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$q = isset($_GET['q']) ? trim((string) $_GET['q']) : '';
if (strlen($q) > 100) {
$q = substr($q, 0, 100);
}
?>
<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="utf-8">
<title>Search demo</title>
</head>
<body>
<form method="get" action="">
<label>Search
<input type="search" name="q" value="<?= e($q) ?>">
</label>
<button type="submit">Go</button>
</form>
<?php if ($q !== ''): ?>
<p>You searched for: <strong><?= e($q) ?></strong></p>
<?php endif; ?>
</body>
</html>
Try ?q=<img src=x onerror=alert(1)>. With escaping you should see the tags as text. Remove e() once and you will see why the helper exists. Length-limiting is validation; e() is escaping — both belong, neither replaces the other. If you later save searches to a database, keep using prepared statements and still escape when listing history on the page.
Escaping is necessary but not the only layer:
htmlspecialchars / e().HttpOnly, Secure, SameSite) so a successful XSS steals less (set these when you start the session on login pages).If you only do item 1 carefully, you already avoid the majority of reflected XSS in small PHP apps. The rest matters as the app grows.
Twig’s default print syntax (double braces around the variable) escapes automatically; Twig autoescape-off blocks and the raw filter are the foot-guns. Blade’s default print syntax escapes; Blade’s unescaped print syntax does not. Laravel and Symfony did not invent a different rule — they made the safe default harder to skip. In plain PHP you must build that default yourself with e() and code review. When you paste old Stack Overflow snippets that echo $_GET into HTML, treat them as vulnerable until proven otherwise.
| Character | Entity with ENT_QUOTES | Why it matters |
|-----------|------------------------|----------------|
| & | & | Must be first conceptually; otherwise other entities break |
| < | < | Stops new tags |
| > | > | Closes tags early in some parsers |
| " | " | Protects double-quoted attributes |
| ' | ' or ' depending on flags/doctype | Protects single-quoted attributes |
You do not need to memorise the entities — call the function. You do need to remember that ampersand appears in normal text (“AT&Tâ€, query strings). Escaping turns it into & in HTML, which is correct. If you see a bare & followed by random letters in a page that should show a company name, someone forgot to escape.
When you review PHP that prints variables, ask:
e() / htmlspecialchars call at the echo site?A five-second glance catches most holes. Automated tools (Psalm, PHPStan with security plugins, or grep for echo $_) help but do not replace knowing the rule. Pair this review with SQL review for string-concatenated queries — the sister bug class to XSS.
Escape on output, validate on input, parameterise your SQL. That trio removes the majority of the boring, dangerous bugs in small PHP apps.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Wire Composer PSR-4 autoload in PHP: map App\ to src/App/, require vendor/autoload.php, dump-autoload, and fix the usual class-not-found mistakes.
Use Europe/London in PHP the safe way: date_default_timezone_set, DateTimeImmutable, GMT vs BST, store UTC, display dd/mm/YYYY, and avoid DST surprises.
Validate PHP uploads with UPLOAD_ERR_OK, finfo MIME detection, size limits and random names — and ignore $_FILES type and the original filename.