Get listed

PHP security resources: php.net, OWASP ini, composer audit

Seven library cards, one faded coral.

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_mode is mandatory and still defaults off. They say session.use_only_cookies=1, session.cookie_httponly=1, and session.cookie_secure=1 on HTTPS. They name SameSite=Lax or Strict as of PHP 7.3.
  • Filesystem Security is the include and upload chapter. Pair it with a realpath pin, 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.

Source to sink. Psalm marks the path. PDO or escapeHtml is the hatch.
$_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 name22 Aug 2026
php.net SecurityAlive. Start here.
OWASP PHP ini sheetAlive. Apply the flags.
Roave Security AdvisoriesAlive. dev-latest in require-dev.
fabpot checkerArchived 2 Aug 2024. Use composer audit.
bepsvpt/secure-headersRepo loads. Prefer explicit header calls you can grep.
phpseclib/phpseclibRepo loads. Use it for SSH and RSA you do not want to invent.
defuse/php-encryptionRepo loads. Not a password hash. Keep password_hash.
Sonar PHP / ExakatProduct 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.

  1. In the app repo, run composer audit --format=plain. A known advisory is a fail. An abandoned package is a ticket, not a shrug.
  2. Confirm roave/security-advisories is in require-dev. Run composer update on 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.
  3. 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.
  4. Call userByEmail from 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.
  5. Run ./vendor/bin/psalm --taint-analysis on the same tree. A hit from $_GET into echo or into a dotted query() 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.

Mauro Chojrin

Mauro Chojrin / About Author

Mauro is a PHP Trainer and Consultant. Heโ€™s been involved in the IT Industry since the year 1997 in a wide array of positions, going from which include technical support, development, team leadership, IT Management and, off course, teaching. Currently Mauroโ€™s focus is on in-company training and consulting but also maintaining his blog and YouTube channel where he shares his knowledge with the world. LinkedIn | Twitter