
PHP security resources are only useful if they point at a current tool and a check you can run this week.
Archived checkers, dead Twitter accounts, and a 2014 book can still appear in ‘best of’ lists. Composer audit and the current PHP supported-versions table are the ones that still move a repo.
The usual mistake is bookmarking seven links and never adding composer audit to CI.
This page is the 2026 shortlist: what is still maintained, what to drop, and where each link fits in a build.
Fabien Potencier archived local-php-security-checker on 2 August 2024. The README now says use composer audit. PHP 8.2 loses official patches on 31 December 2026. As of 22 August 2026, php.net supported-versions table: 8.3 is security-only through 31 December 2027, 8.4 is in active support until 31 December 2026 and security-only through 31 December 2028, and 8.5 shipped on 20 November 2025.
A screenshot is not a control. The control is a first-party chapter you can still open, an ini you can prove, and a lockfile check that still has a maintainer. Keep the secure coding checklist next to this shelf. Read injection when the string is SQL. Read XSS when the sink is HTML. Read HTTP headers when the package only sets Content-Security-Policy.
The 2021 list aged in public
Seven names from that page still resolve in a browser today. One does not, as a living project. GitHub page for Fabien’s checker the same day As of 2 August 2024, version table. The banner says the owner archived it. Last release was v2.1.3 on 9 May 2024. The replacement line is COMPOSER_AUDIT_ABANDONED=ignore composer audit. That is a dead 2021 link with an honest successor, not a 404.
SensioLabs security.sensiolabs.org, the hosted checker that predated the binary, has been gone for years. I am not treating a parked domain as a resource. If a tutorial from 2019 still posts a token to that host, delete the job.
A package that still loads is not automatically a 2026 control. bepsvpt/secure-headers, phpseclib, and defuse/php-encryption still answer HTTP 200. They sit in the survivors section. They do not replace the language or the ini.
php.net Security is still the first chapter
https://www.php.net/manual/en/security.php. The chapter tree is still there: general considerations, CGI, Apache module, session security, filesystem, database, error reporting, user submitted data, hiding PHP, keeping current. That is resource one. It is not a blog. It is the project’s own map.
Three pages from that tree do the work most lists skip:
- Database: SQL Injection says never trust user input inside a query, and it points at PDO and MySQLi prepared statements. That is the bind this page later writes.
- Sessions and Security plus Securing Session INI Settings say
session.use_strict_modeis mandatory and still defaults off. They saysession.use_only_cookies=1,session.cookie_httponly=1, andsession.cookie_secure=1on HTTPS. They nameSameSite=LaxorStrictas of PHP 7.3. - Filesystem Security is the include and upload chapter. Pair it with a
realpathpin, not with a client filename.
Resource one is not a composer package. Bookmark the chapter. Open the supported-versions table on the same day you bump a dependency. 8.1 ended 31 December 2025. After 31 December 2026 there is no official patch for a new interpreter bug on 8.2. Move production to 8.4 or 8.5 unless you have a named reason to sit on 8.3.
OWASP still publishes the php.ini sheet
Resource two is the OWASP PHP Configuration Cheat Sheet. It still loads. It still tells you to set expose_php=Off, display_errors=Off, allow_url_fopen=Off, allow_url_include=Off, and to turn file_uploads off if the app does not take files. It still lists strict session mode, a Secure cookie, HttpOnly, and SameSite=Strict.
The sheet’s own intro still points at Paragonie’s 2018 PHP Security Guide and at Awesome PHP Security. Those are reading lists with a 2018 date on the first one. Use them as history. Do not treat a 2018 guide as the patch calendar. The ini values and the php.net chapter are the 2026 page.
OWASP’s disable_functions list is a host-policy extra, not a sandbox. The manual describes the directive as a way to turn functions off. It does not claim a jail. If you disable exec and friends, still keep the process user small and still bind SQL. A shared host that only flips that line is not a hardened app.
; production php.ini fragment this page will prove
expose_php = Off
display_errors = Off
log_errors = On
allow_url_fopen = Off
allow_url_include = Off
session.use_strict_mode = 1
session.use_only_cookies = 1
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = Lax
session.cookie_lifetime = 0
I set Lax on the session cookie here, not Strict, because a login redirect from a payment host still needs the cookie on a top-level GET. If every mutation is same-origin and you have no foreign checkout, Strict is the tighter OWASP line. Pick one on purpose. Leave the default empty string only if you enjoy a browser guess.
composer audit replaced the 2021 binary
Resource three is composer audit. Composer CLI page while checking Packagist. The audit command checks installed packages against Packagist’s security advisory API. It reports vulnerable, abandoned, and malware-flagged packages. composer require, update, and install can run the same check after the lockfile changes unless you pass --no-audit.
# CI job on a tree you own. Fail closed on a known advisory.
composer audit --format=plain
# Same check after a lockfile change. Do not pass --no-audit.
composer update --with-all-dependencies
The archived checker’s README tells you to set COMPOSER_AUDIT_ABANDONED=ignore if you only want the old “known CVE” behavior. Abandoned is a separate signal. Fail on vulnerable. Decide abandoned per package. Do not ignore the whole class because one carbon library went quiet.
Roave is the install-time conflict
Resource four is roave/security-advisories. It still loads. It is a metapackage whose conflict block lists versions with published advisories. Composer then refuses to resolve a lockfile that includes those versions. That is the resolve-time belt. Audit is the report. Roave is the hard stop on composer update.
composer require --dev roave/security-advisories:dev-latest
Keep it in require-dev so production images do not ship a conflict-only package. Roave pulls from FriendsOfPHP/security-advisories and the GitHub Advisory Database. Packagist’s API is what composer audit reads. Those are two belts, not one source named twice.
$_GET / $_POST | v Psalm --taint-analysis sql ----> PDO::prepare + bind html ---> escapeHtml (ENT_QUOTES | ENT_SUBSTITUTE) file ---> refuse user paths | v composer audit (Packagist API) roave/security-advisories (FriendsOfPHP + GHSA)
Psalm traces user input to sinks
Resource five is Psalm’s security analysis. that page while checking the extras. --taint-analysis still exists. Default sources are $_GET, $_POST, and $_COOKIE. Default sinks include echo, include, and header. Taint kinds include sql, html, shell, unserialize, and file. Psalm’s own docs say parameterized queries and a context-aware template beat a homemade escape. That matches the bind later on this page.
# in the app repo you maintain
./vendor/bin/psalm --taint-analysis --threads=4
SonarQube’s PHP analyzer and Exakat still have product pages. They are extras, not the first five. If your shop already pays for Sonar, keep the PHP rules on. Do not install a second engine because a 2021 list had a screenshot. Psalm’s source-to-sink pass is the one I can run from Composer without a server.
PHPStan is a type checker. I did not find a first-party taint mode on phpstan.org that matches Psalm’s --taint-analysis flag. Use PHPStan for types. Use Psalm for the source-to-sink pass, or use both if the team already has both configs.
The language already has the binds
Resource six is not a GitHub star count. It is the three functions the manual already documents: PDO::prepare, htmlspecialchars, and password_hash. A shelf that only lists packages teaches people to skip the runtime. The identifiers on this page stay userByEmail, escapeHtml, and hashSecret.
function userByEmail(PDO $pdo, string $email): ?array {
$stmt = $pdo->prepare(
"SELECT id, password_hash FROM account WHERE email = :email"
);
$stmt->execute(["email" => $email]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row === false ? null : $row;
}
function escapeHtml(string $text): string {
return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");
}
function hashSecret(string $plain): string {
return password_hash($plain, PASSWORD_DEFAULT);
}
function secretMatches(string $plain, string $stored): bool {
return password_verify($plain, $stored);
}
Set the connection once. ATTR_EMULATE_PREPARES false asks the server for a real prepare. That is two channels: statement text, then values. Concatenating into query() is one channel. The SQL Injection page is the citation.
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
Identifiers still cannot be bound. A sort token from the query string maps to a column you wrote.
const SORTS = [
"created" => "created_at",
"name" => "display_name",
];
function listAccounts(PDO $pdo, string $sortToken): PDOStatement {
$column = SORTS[$sortToken] ?? "created_at";
$stmt = $pdo->prepare("SELECT id FROM account ORDER BY {$column} DESC");
$stmt->execute();
return $stmt;
}
What from 2021 still loads
This is the leftover of the 2021 list, not a second chapter. Those URLs returned 200. They are extras, not the first six.
| 2021 name | 22 Aug 2026 |
|---|---|
| php.net Security | Alive. Start here. |
| OWASP PHP ini sheet | Alive. Apply the flags. |
| Roave Security Advisories | Alive. dev-latest in require-dev. |
| fabpot checker | Archived 2 Aug 2024. Use composer audit. |
| bepsvpt/secure-headers | Repo loads. Prefer explicit header calls you can grep. |
| phpseclib/phpseclib | Repo loads. Use it for SSH and RSA you do not want to invent. |
| defuse/php-encryption | Repo loads. Not a password hash. Keep password_hash. |
| Sonar PHP / Exakat | Product pages load. Optional if Psalm taint already runs. |
If you already ship bepsvpt/secure-headers, keep it only if you can print the headers it sets and match them to the HTTP headers guide. A magic middleware you never open is how X-Powered-By survives. Prefer six header() lines you own over a config file you copied once.
header("X-Content-Type-Options: nosniff");
header("Referrer-Policy: strict-origin-when-cross-origin");
header("X-Frame-Options: DENY");
header("Content-Security-Policy: default-src 'self'; frame-ancestors 'none'");
phpseclib is a fine SSH and public-key library when you must speak those protocols from PHP. It is not a substitute for TLS on the site or for password_hash on an account row. defuse/php-encryption is authenticated encryption for a blob you will decrypt later. It is the wrong tool for a login password.
Prove the audit and the ini
You are not scanning a foreign host. You are proving your own tree and your own process.
- In the app repo, run
composer audit --format=plain. A known advisory is a fail. An abandoned package is a ticket, not a shrug. - Confirm
roave/security-advisoriesis inrequire-dev. Runcomposer updateon a copy of the lockfile after you pin a version that the advisory database marks. Expect the resolver to refuse. Do not keep that pin. - Dump the runtime ini on a box you own:
php -i | rg "expose_php|display_errors|allow_url_fopen|session.use_strict_mode|session.cookie_". Expect the fragment this page wrote, including strict session mode set to 1. - Call
userByEmailfrom a unit test with an email that contains a quote. The prepared statement still returns one row or none. It does not change the SQL text. - Run
./vendor/bin/psalm --taint-analysison the same tree. A hit from$_GETintoechoor into a dottedquery()is a review.
# prove session flags on a login you already own
curl -sS -D - -o /dev/null -X POST "https://your-app.example/login" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "email=you@your-app.example&password=REDACTED"
# Expect: Set-Cookie:...; Secure; HttpOnly; SameSite=Lax
Grep the hatches this page names as greps:
rg -n "mysqli_query\s*\(|->query\s*\(\s*[\"'\`].*\$|local-php-security-checker|security.sensiolabs.org|unserialize\s*\(" --glob '!vendor'
A hit on a dotted query, on the archived binary, on the dead SensioLabs host, or on unserialize is a review. A hit on userByEmail and composer audit is the path you want.
Questions we keep getting
Is Roave enough if CI already runs composer audit?
Audit reports. Roave refuses the resolve. Keep both. A green human reading a log is slower than a conflict that stops the lockfile.
Should I still install bepsvpt/secure-headers?
Only if you can dump the response and name each header. Six explicit header() calls are easier to review. The package still loads. It is not required once the HTTP headers guide is next to this page.
Did the 2021 Exakat and Sonar pages die?
No. Both product sites still respond. They are optional extras. The --taint-analysis flag is what this page can run from Composer without standing up a server. If Sonar already runs on the repo, keep the PHP rules. Do not add Exakat just to match a 2021 screenshot.



