Tutorial
Safe PHP Includes: A Tutorial from 2005, Revisited in 2026
One of the first things I ever learned in PHP was also one of the first things I learned to break. Twenty-something years later, I'm still using the same idea.I remember the first time somebody showed me how PHP includes worked. At the time, I was used to building individual HTML pages. You wanted an About page? You made about.html. A Contact page? contact.html. And unless you were particularly organized, each one had its own copy of the header, menu, and footer.
Then somebody showed me that you could put a variable in the URL and tell PHP which file to include. Something like this:
<?php
include $_GET['page'];
?>
Visit index.php?page=about.php, and suddenly the About page appeared inside your existing layout. That was it. One template, multiple pages, and no need to duplicate the surrounding HTML. I thought it was brilliant. I never really left PHP after that. 😛Of course, there was one rather significant problem. You had just given the visitor control over which file your server included. And that could go very badly.
The original problem
The simplest version of this technique trusted whatever the visitor put into the URL. If somebody requested:index.php?page=about.php
Everything worked beautifully. But what happened if somebody changed about.php to a relative path pointing somewhere else? That was the problem with the original approach. PHP wasn't being asked to select an approved page. It was being handed a filename supplied by the visitor.
Back then, a lot of us were learning by experimenting. You found some PHP code, tried it, broke it, figured out why it broke, and eventually wrote something better. I knew about directory traversal. I'd experimented with it myself. But knowing a vulnerability existed and successfully exploiting a particular website weren't always the same thing, especially when every webmaster organized their files differently.
Back when everybody wrote their own weird PHP
One thing I remember about that era was how wildly inconsistent everybody's code could be. There wasn't necessarily one standard approach to organizing a website. I had my own folder structure, my own filename conventions, and my own peculiar ways of connecting everything.A database connection file wasn't necessarily called config.php. Mine might be con.sql.php. Other files had names such as config.hdr.php. Sometimes I used entirely different extensions, with the application handling the filename internally.
Was that perfect security? Absolutely not. But somebody trying common filenames didn't necessarily get very far. They needed to know something about how I'd actually organized the website. That was part of how we thought about protecting homemade applications: don't blindly copy somebody else's implementation. Understand the code, change it, and make the assumptions attackers were relying on less predictable.
There was value in that approach, particularly against opportunistic guessing. There was also an important limitation: obscurity could make a vulnerable file harder to discover, but it didn't actually fix the vulnerability. I eventually wanted something more dependable.
My old clean() function
Years later, a version of my dynamic include system was still running on my dad's old artwork website. I recently dug up the original PHP files, and there it was. This is the actual function, reformatted slightly for readability:function clean($page) {
$page = $page . '.php';
$loc = array('pages/', 'php/');
$check = file_exists($loc[0] . $page)
? 'pages/' . $page
: NULL;
if (is_null($check)) {
$check = file_exists($loc[1] . $page)
? 'php/' . $page
: 'php/404.php';
}
return $check;
}
I rather like finding old code like this. Not because I'd write it exactly the same way today, but because I can still recognize what I was trying to accomplish.The idea was straightforward. First, append .php to the requested page name. Then check whether the file exists in pages/. If it doesn't, check php/. If neither location contains the file, return the 404 page. And, most importantly for my workflow, I didn't need to add every new page to a manually maintained routing array. Drop the file into the appropriate folder, and the loader could find it. That convenience was the whole point.
So was it safe?
Safer in some respects than blindly including the original request? Yes. Actually secure against directory traversal? No.The function checked whether a file existed, but it didn't prevent ../ from appearing in the requested name. That meant someone who knew or discovered a suitable path could potentially reach outside the intended folders. My unusual filenames and directory organization could frustrate guessing, but they weren't reliable access controls.
There's another detail: the old function would include a 404 page for an unknown request, but that alone didn't guarantee the server returned an actual HTTP 404 status.
In hindsight, these are exactly the things I'd correct. Not by replacing the entire idea with a framework. Just by tightening the boundaries.
The 2026 version
Let's rebuild the original concept. We're going to keep the things I still like about it:- One shared website layout.
- A directory containing the pages we want to make available.
- Automatic discovery of those pages.
- Clean, readable URLs.
- No manually maintained list of every page.
- A proper 404 when something doesn't exist.
Step 1: Organize the files
For this example, we'll use a structure like this:website/
|
|-- public_html/
| |-- index.php
| |-- .htaccess
| |
| `-- errors/
| `-- 404.php
|
`-- pages/
|-- home.inc.php
|-- about.inc.php
|-- articles.inc.php
`-- contact.inc.php
Notice that the pages directory sits outside public_html. That means someone can't simply request one of the content fragments directly through their browser.The .inc.php suffix is primarily an organizational convention. The real protections come from which files we permit, where they are stored, and who has permission to modify them. Only trusted PHP content should go in this directory. Never let visitors upload executable files into it.
Step 2: Discover the permitted pages
Here's the modern replacement for my old clean() function. I've renamed it findPage() because that's a more accurate description of what it actually does.<?php
function findPage($request, $directory) {
// Page names may contain letters, numbers and hyphens.
// No slashes, dots or filesystem paths.
if (
!is_string($request) ||
!preg_match(
'/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/',
$request
)
) {
return null;
}
// Find our available content files automatically.
$files = glob($directory . '/*.inc.php') ?: [];
foreach ($files as $file) {
// Ignore symbolic links and non-files.
if (is_link($file) || !is_file($file)) {
continue;
}
// about.inc.php becomes about
$pageName = basename($file, '.inc.php');
// Only select an exact page-name match.
if ($pageName !== $request) {
continue;
}
// Resolve the file's actual location.
$realFile = realpath($file);
// Ensure it really belongs in our pages folder.
if (
$realFile !== false &&
dirname($realFile) === $directory
) {
return $realFile;
}
}
return null;
}
There it is. A function that discovers available content files, compares their names against the requested page, and returns the matching file. No central list of page names. No router configuration to update every time you write another article. It still feels remarkably close to what I was trying to accomplish all those years ago.
What's different?
We're no longer taking the requested page name and appending it directly to a directory path. We're inspecting trusted files that already exist.The requested name must match a strict identifier format, and the selected file must resolve to a regular file directly inside the intended directory. If the request contains directory traversal characters, it fails validation before any page is selected. And if the requested file isn't found, the function returns null. Simple.
Step 3: Put it into our website
Now we need to use our function inside index.php. For clarity, this example keeps everything in one file, although you can move findPage() into a separate PHP include if you prefer. Place the function from Step 2 above this code:<?php
// Our content directory is outside public_html.
$directory = realpath(
dirname(__DIR__) . '/pages'
);
// A missing content directory is a server problem.
if ($directory === false || !is_dir($directory)) {
http_response_code(500);
exit('Content directory unavailable.');
}
// Default to the homepage.
$request = $_GET['page'] ?? 'home';
// Find the corresponding trusted file.
$content = findPage($request, $directory);
// Unknown page? Return a proper HTTP 404.
if ($content === null) {
http_response_code(404);
$content = __DIR__ . '/errors/404.php';
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1">
<title>My PHP Website</title>
</head>
<body>
<header>
<h1>My Website</h1>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/articles">Articles</a>
<a href="/contact">Contact</a>
</nav>
</header>
<main>
<?php require $content; ?>
</main>
<footer>
<p>Still running PHP.</p>
</footer>
</body>
</html>
The magic happens in two places. First, $content = findPage($request, $directory); picks the file. Then <?php require $content; ?> loads it into the layout.At that point, $content is either a file selected from our trusted pages directory or our explicitly chosen 404 page. It isn't an unchecked filename from the URL. That's a much better boundary. And unlike the old version, we're sending a real 404 response when a page doesn't exist.
Step 4: Make the URLs pretty
Personally, I've always preferred /about over index.php?page=about. For a basic Apache website, we can use a small .htaccess rewrite:RewriteEngine On
# Leave actual files and directories alone.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# /about becomes index.php?page=about
RewriteRule ^([a-z0-9]+(?:-[a-z0-9]+)*)/?$ index.php?page=$1 [L]
This assumes Apache has mod_rewrite enabled and that the rules are permitted by the server configuration. If you already have rewrite rules, integrate this with them rather than replacing everything.The PHP loader doesn't actually require URL rewriting. You can still visit index.php?page=about without it. Rewriting just gives us nicer addresses. Now the URLs look like this:
/ -> home.inc.php
/about -> about.inc.php
/articles -> articles.inc.php
/contact -> contact.inc.php
/not-a-page -> HTTP 404
And here's my favorite part. Create a new file called pages/my-new-page.inc.php and put something inside it:
<h2>My New Page</h2>
<p>Hello from my very own PHP include!</p>
Now visit /my-new-page. That's it. No changes to index.php. No new routing entry. No additional configuration.Obviously, you'd still need to add a link somewhere if you wanted visitors to discover it through the site's navigation. But the page itself is available. Drop a file in the folder. The website knows it exists. That was the part I loved in the first place.
Step 5: Understanding what we've actually secured
I want to emphasize something here because I think it's an important lesson. This system isn't secure because it uses an unusual extension or because the files have difficult-to-guess names. It isn't secure simply because we used preg_match() either. The improvement is that we've restricted the visitor to selecting a known, trusted file from a controlled directory. The important protections are- Restricted page identifiers — the browser can submit a simple name such as about or my-new-page, not an arbitrary path.
- Automatic discovery of trusted files — PHP discovers available pages from a directory we control. It doesn't construct an arbitrary include path from visitor input.
- Resolved path verification — before returning a file, the function confirms that its actual location is inside the expected directory.
- A content directory outside the public web root — visitors cannot directly request the content fragments through ordinary HTTP paths.
- Trusted file ownership — arguably the most important operational rule. If an attacker can upload or modify PHP files inside that directory, the loader's checks won't save you.
So why not just use a framework?
You absolutely can. There are good reasons frameworks exist, and plenty of projects benefit from them. But I don't think every small website needs one. Sometimes you want a shared header, a footer, a few dynamically included pages, and complete control over what happens between them. I'm still perfectly comfortable building that with procedural PHP. In fact, AnimeHistory.org is largely built around that same philosophy today.I use AI tools to help with research and development, but I still need to understand the code I'm running. I test it, adjust it, debug it, and occasionally discover that my first idea wasn't quite as clever as I thought. That part of being a webmaster hasn't changed much. And when I revisit the code months later, I'd much rather find a little function I can understand than a complicated system I have to spend an afternoon deciphering.
Twenty-something years later
Looking back at my original clean() function, I can see both what I got right and what I didn't. I liked automatic page discovery. I liked keeping the layout separate from its content. I liked having one place responsible for deciding what page to display. Those were useful ideas.But existence checks weren't sufficient protection against traversal, unusual filenames weren't an authorization system, and including an arbitrary internal PHP file was a risk I wouldn't accept in a new implementation. The solution wasn't to abandon dynamic includes. It was to make the boundary explicit.
And that's what I rather like about revisiting old tutorials. Sometimes the thing you were trying to accomplish was perfectly reasonable, even if your original implementation needed a little work. I learned PHP because somebody showed me a tiny piece of code that opened up a whole new way of building websites. More than twenty years later, I'm still building them. And apparently I'm still finding excuses to use include. 😛
~ edited Oct 31, 2026