Lesson 19 of 20

PHP Best Practices

The Security Rules That Carry the Most Weight

Security advice tends to arrive as a long list, which makes it hard to know what actually matters. In practice, a small number of habits prevent the overwhelming majority of problems in PHP applications, and they are all things you have already met in this course.

The single most important is that every value from outside your program goes into a query as a bound parameter. Not most values, not the risky-looking ones. Every one. SQL injection is still the flaw that causes the largest breaches, and prepared statements close it completely — but only if the rule has no exceptions, because the one query you concatenated "just this once" is the one that gets found.

The second is that every value you print gets escaped for the format you are printing into. htmlspecialchars() for HTML, json_encode() for JSON, urlencode() for a URL parameter. Escape at the moment of output, never on the way into storage, and never assume data is safe because it came from your own database — it was typed by a user at some point.

The third is that authorisation is checked on the server, on every request. Hiding a button is not access control. A logged-in user editing an id in a URL is the most common way records get read and modified by the wrong person, and the fix is a WHERE ... AND user_id = ? on every query that touches user-owned data.

Everything else on the list below matters too, but if you get those three consistently right you have avoided the failures that end up in the news.

  • Prepared statements for every query with a value in it — no exceptions, no "just this once"
  • Escape on output with htmlspecialchars($v, ENT_QUOTES, 'UTF-8'), including in loops and error messages
  • Check authorisation on the server for every request, not just when rendering the menu
  • Store passwords with password_hash() and check with password_verify(); never MD5, SHA-1 or your own scheme
  • CSRF tokens on every state-changing form, compared with hash_equals()
  • Session cookies with httponly, secure and samesite, and session_regenerate_id(true) at login
  • Validate against an allow-list — accept known-good values rather than trying to spot bad ones
  • Uploads: check the real content type, rename the file, and store it where scripts cannot execute
  • HTTPS everywhere, so session cookies and passwords never cross the network in the clear
  • Keep PHP and your Composer dependencies on supported versions, and run composer audit occasionally
Notes
  • Security is not a feature you add at the end. Every one of these is cheaper to build in from the first line than to retrofit into a finished project, and retrofitting is how things get missed.

Keeping Secrets Out of Your Code

Database passwords, API keys and mail credentials do not belong in the files you commit to git. This is not a theoretical concern: automated tools scan public repositories continuously, and a key pushed to a public repo is typically found and abused within minutes. A private repository is only marginally better, because repositories get shared, forked and made public by accident.

The standard arrangement is to keep configuration outside the code. A .env file holding key-value pairs, listed in .gitignore so it is never committed, alongside a .env.example that is committed and shows which keys exist without their values. Reading it is one line with a library such as phpdotenv, or you can write a small loader yourself.

Where the file physically sits matters too. Nothing sensitive should live inside the folder your web server serves. The recommended layout puts only a public/ directory in the web root, with everything else — classes, configuration, templates, uploads, logs — one level above it, where no URL can reach them.

This matters because a misconfigured server can serve a .php file as plain text, and a .env file inside the public folder is downloadable by anyone who guesses the name. Both have happened to plenty of real sites.

If a credential ever does get committed, remember that deleting it in a later commit is not enough — the value is still in the repository's history. The only real fix is to rotate the secret: change the password or regenerate the key so the exposed one stops working.

Example
<?php
// .env  (never committed)
// DB_HOST=localhost
// DB_NAME=campus
// DB_USER=campus_app
// DB_PASS=a-long-random-password
// APP_ENV=production

// .env.example  (committed, shows the shape without the values)
// DB_HOST=
// DB_NAME=
// DB_USER=
// DB_PASS=
// APP_ENV=development

// .gitignore
// .env
// vendor/
// storage/uploads/
// storage/logs/

// config/database.php - returns an array, reads from the environment
return [
    'host' => getenv('DB_HOST') ?: '127.0.0.1',
    'name' => getenv('DB_NAME') ?: 'campus',
    'user' => getenv('DB_USER') ?: 'root',
    'pass' => getenv('DB_PASS') ?: '',
];

// Recommended folder layout
// project/
//   public/          <- the web root; the ONLY folder reachable by URL
//     index.php
//     assets/
//   src/             <- your classes
//   config/
//   views/
//   storage/         <- uploads, logs, cache
//   vendor/
//   .env             <- outside public/, and in .gitignore
Notes
  • Different values for development and production should differ only in the .env file, never in the code. If your deployment involves editing PHP files by hand to change a password or flip a debug flag, that step will eventually be forgotten — usually in the direction that leaves debugging on.

Write Modern PHP, Not PHP From 2010

A great deal of PHP material online was written for PHP 5, and following it produces code that is harder to read and easier to get wrong. Modern PHP has genuinely better tools for the same jobs, and using them is not about fashion — each one removes a class of mistake.

Start every file with declare(strict_types=1); and put type declarations on every parameter, return value and property. This is the highest-value change you can make. Types turn a whole family of silent wrong-value bugs into immediate, located errors, and they make your editor able to help you.

Prefer match to switch when producing a value, because it compares strictly, needs no break, and shouts when a case is missing. Prefer enums to loose string constants for anything with a fixed set of states. Use constructor promotion and named arguments to make object creation short and readable. Use ?? for defaults, str_contains() instead of strpos() !== false, and readonly for values that must never change after construction.

There are also a few features to stay away from. eval() executes a string as code and is essentially never necessary. extract() turns array keys into variables, which makes it impossible to tell where a variable came from. Variable variables ($$name) do the same damage. The @ suppression operator hides messages you need. And global makes functions depend on things their signature does not mention.

Finally, be sceptical of code you copy. If a snippet uses mysql_query(), ereg(), magic quotes or register_globals, it predates PHP 5.4 and its security assumptions are as outdated as its syntax.

Example
<?php
declare(strict_types=1);

// Old style
function getDiscount($type, $amount) {
    switch ($type) {
        case 'student': return $amount * 0.5;
        case 'staff':   return $amount * 0.8;
    }
    return $amount;
}

// Modern equivalent: typed, strict, and loud about missing cases
enum CustomerType: string
{
    case Student = 'student';
    case Staff   = 'staff';
    case General = 'general';
}

function discountedPaise(CustomerType $type, int $paise): int
{
    return match ($type) {
        CustomerType::Student => (int) round($paise * 0.5),
        CustomerType::Staff   => (int) round($paise * 0.8),
        CustomerType::General => $paise,
    };
}

// Readable object creation
final class Enquiry
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
        public readonly string $message,
        public readonly ?string $phone = null,
    ) {}
}

$enquiry = new Enquiry(
    name:    trim($_POST['name'] ?? ''),
    email:   trim($_POST['email'] ?? ''),
    message: trim($_POST['message'] ?? ''),
);

// Things to avoid
// eval($code);                    // executes a string as code
// extract($_POST);                // creates variables from user input
// $$fieldName = 'x';              // variable variables
// @unlink($path);                 // hides the reason it failed
// global $pdo;                    // hidden dependency
Notes
  • Check which PHP version your host actually runs before relying on a feature. Enums and readonly need PHP 8.1; match, named arguments and constructor promotion need 8.0. php -v on the server, or a temporary phpinfo() page, answers the question in seconds.

Structure: Namespaces, Autoloading and a Front Controller

A project of thirty files needs organisation, and PHP's answer has two parts: namespaces to keep names apart, and autoloading to stop you writing require by hand.

A namespace is a prefix for class names. Declaring namespace App\Models; at the top of a file means the Student class in it is really App\Models\Student, which can coexist happily with a Student from a library. In another file you write use App\Models\Student; once and then refer to it by its short name.

Autoloading is the part that removes the tedium. Composer's PSR-4 standard maps a namespace prefix to a folder, so App\Models\Student is expected in src/Models/Student.php. Once configured, a single require __DIR__ . '/../vendor/autoload.php' at the top of your entry point is all you need — every class loads itself on first use. This is why the one-class-per-file convention matters: the autoloader finds files by name.

The last structural piece is a front controller: instead of one PHP file per page, every request goes to public/index.php, which loads the bootstrap and decides what to run. That gives you exactly one place to start the session, configure error handling and check authentication, rather than repeating those lines in every file and eventually forgetting one.

None of this requires a framework. Composer, a namespace, an autoload entry and a small router are perhaps forty lines in total, and they make a plain-PHP project feel dramatically more organised. It is also exactly the structure Laravel and Symfony use, so the work transfers directly when you move to one.

Example
<?php
// composer.json
// {
//   "autoload": {
//     "psr-4": { "App\\": "src/" }
//   }
// }
// then run:  composer dump-autoload

// src/Repositories/StudentRepository.php
namespace App\Repositories;

use PDO;

final class StudentRepository
{
    public function __construct(private PDO $db) {}

    public function find(int $id): ?array
    {
        $stmt = $this->db->prepare('SELECT * FROM students WHERE id = ?');
        $stmt->execute([$id]);
        return $stmt->fetch() ?: null;
    }
}

// public/index.php - the single entry point
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../bootstrap.php';      // errors, session, config

use App\Repositories\StudentRepository;

$pdo      = require __DIR__ . '/../config/pdo.php';
$students = new StudentRepository($pdo);

$route = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?? '', '/');

match ($route) {
    ''         => require __DIR__ . '/../views/home.php',
    'students' => require __DIR__ . '/../views/students.php',
    'contact'  => require __DIR__ . '/../views/contact.php',
    default    => (function () {
        http_response_code(404);
        require __DIR__ . '/../views/404.php';
    })(),
};
Notes
  • require stops the script when the file is missing; include only warns and carries on. For anything your code depends on — a class file, a config file, the autoloader — use require, because continuing without it just produces a stranger error further down. Use require_once for files that define things, so a double include cannot cause a "cannot redeclare" fatal.

Style and Tools That Do the Checking for You

Consistent formatting is not about aesthetics — it is about being able to read a diff and see what actually changed, rather than a mix of real edits and re-indentation. The PHP community settled this argument with published standards, so you do not have to have the argument at all.

PSR-12 (and its successor, the PER Coding Style) defines indentation, brace placement, spacing and the order of keywords. PSR-4 defines the autoloading layout described above. Adopt them and every PHP developer who opens your project already knows how to read it.

Better still, let tools apply and check them. A formatter reformats your code to the standard automatically; a static analyser reads your code without running it and reports bugs the language does not catch, such as a method that can return null being used where a string is required. Static analysis is the closest thing to a free upgrade in PHP — it finds real bugs in code that already "works".

Tests are the other half. You do not need to test everything to benefit; testing the parts that would be expensive to get wrong — fee calculations, validation rules, permission checks — pays for itself the first time a change would have broken one silently. PHPUnit is the standard tool, and a test is simply a small function that calls your code and asserts what should come back.

Finally, use version control from the first commit, even on a solo college project. Being able to see what changed and to return to yesterday's working state is worth far more than the five minutes it takes to set up.

  • PSR-12 / PER Coding Style — the formatting standard; PSR-4 — the autoloading standard
  • PHP-CS-Fixer or PHP_CodeSniffer — reformat code to the standard automatically
  • PHPStan or Psalm — static analysis; start at a low level and raise it as you clean up
  • PHPUnit — unit tests, run with one command and on every push
  • Xdebug — step through code line by line instead of scattering var_dump() calls
  • Composer — dependencies, autoloading, and composer audit for known vulnerabilities
  • git — with a .gitignore that excludes vendor/, .env and uploaded files
  • An editor with a PHP language server — VS Code with Intelephense, or PhpStorm — so type errors surface as you type
Notes
  • Add these one at a time rather than all at once. Running a formatter over an existing project produces an enormous diff; running static analysis at its strictest setting produces hundreds of findings and gets switched off in frustration. Start at the lowest useful level, fix what it says, then tighten.

Before You Put It on the Internet

The gap between "it works on my machine" and "it is safe to publish" is a short checklist, and going through it deliberately is what separates a project that survives contact with the public from one that becomes a cautionary tale.

Two items on that list deserve emphasis because they are so often skipped. First, error display must be off on the live site and logging must be on — a live PHP site printing warnings is handing out file paths and query structure to anyone who provokes one. Second, take backups and test that you can actually restore them. An untested backup is a hope, not a plan.

It is also worth remembering that a live site is not finished. PHP releases security fixes, and each version is supported for a limited period; once yours falls out of support it stops receiving them. Dependencies need the same attention, which is what composer audit is for.

The list below is short enough to run through every time you deploy, which is exactly the point.

  • display_errors off, log_errors on, and a log file you can actually read
  • All credentials in environment variables or a file outside the web root, and out of git history
  • HTTPS enabled and enforced; session cookies marked secure
  • Only public/ reachable by URL — no source, config, uploads or logs inside it
  • Every query parameterised; every output escaped; every state-changing form carrying a CSRF token
  • Passwords hashed with password_hash(), in a column at least 255 characters wide
  • Uploads validated by content, renamed, and stored where PHP cannot execute them
  • Any leftover phpinfo(), test files, database dumps and .zip archives deleted from the server
  • Automated database backups, and one restore actually tested
  • PHP on a supported version, and composer audit clean
Notes
  • Modern PHP is a very different language from the one its old reputation was built on. Typed, strict, with real enums, a sane error model and first-class tooling, PHP 8 is a perfectly good choice for a serious application — as long as you write it as PHP 8 rather than as PHP 5 with newer syntax.
Ask AI