PHP#
SafeLint analyses PHP source for the Holzmann "Power of Ten" safety rules and PHP-language-specific patterns: function shape (length, nesting depth, cyclomatic complexity, argument count), error-handling discipline (empty catch {} bodies, unlogged catches), global state (the global keyword and $GLOBALS writes), resource lifecycle via try { } finally { }, loop safety (the while (true) / for (;;) shapes with numeric-level break N resolution), suppression hygiene (the @ error-suppression operator), and dataflow taint into eval / exec / unserialize / raw-SQL sinks from the web superglobals. PHP is the 7th registered language and adds no new rule codes: it is the widest port of the existing rule set. SafeLint does NOT replace PHP_CodeSniffer / PHPStan / Psalm; it runs alongside them and targets the engineering-discipline patterns those tools leave alone.
File extensions#
.php, parsed bytree-sitter-php. Picked up bysafelint check(directory mode,--all-filesmode, and the pre-commit hook). SafeLint uses the mixed HTML+PHP grammar (language_php()) so templated.phpfiles parse: HTML outside<?php ?>tags arrives as inerttextnodes that the rules never match, and only the PHP regions inside the tags are analysed.
Quick start#
pip install 'safelint[php]' # adds the tree-sitter-php grammar
safelint check src/ # lint a directory (git-modified files by default)
safelint check --all-files . # lint everything under cwd
safelint check --format json src/ # machine-readable for editors / CI
In a project that already uses uv, add it as a dev dependency:
safelint is a Python package, not a Composer package. A composer.json is not required; safelint walks the source tree directly and auto-detects by file extension. Run it from the project root. If your PHP project has no Python tool chain, pipx install 'safelint[php]' isolates the install.
v2.0.0+ ships every language grammar as an opt-in extra. Plain pip install safelint installs only the engine and would skip every .php file with an install hint (safelint: warning: skipping .php files, install with: pip install 'safelint[php]') on first run. The exit code is 2 only when EVERY candidate file is skipped (the typical PHP-only project); in a mixed Python + PHP repo, safelint emits the .php skip warning and continues linting the supported files normally.
Suppression directives#
PHP supports line-comment directives only (//): // nosafe, // nosafe: SAFE501, // safelint: ignore: SAFE101. The # shell-style and /* */ block-comment forms are a documented non-feature for directives: a # nosafe or /* nosafe */ is parsed as ordinary text and does not suppress anything. Use the // form.
Rules that fire on PHP#
21 rules apply to PHP. PHP is in scope for 13 cross-language rules (the all-language core), plus a set of rules shared with most other languages, plus the try/catch rules and SAFE301. 2 rules are deliberately skipped, see the next section. Like the other languages' optional rules, every dataflow rule and several state / suppression rules are disabled by default.
Cross-language rules#
| Code | Rule | Notes for PHP |
|---|---|---|
| SAFE101 | function_length |
Counts source lines on function_definition / method_declaration / anonymous functions and arrow functions. Default cap is 60. Closure bodies count toward their own size, not the enclosing function. |
| SAFE102 | nesting_depth |
Counts if_statement / for_statement / foreach_statement / while_statement / do_statement / switch_statement / try_statement. Default max 2. Per-arm case nodes are not counted; the switch counts once. |
| SAFE103 | max_arguments |
Counts declared parameters. Variadic ...$args counts as one. Promoted constructor parameters (public int $x) count as parameters. Default cap 7. |
| SAFE104 | complexity |
Cyclomatic complexity: every if / for / foreach / while / do / case / catch adds one; && / \|\| (and and / or) each add one. The default case is not counted (it adds no decision). Default cap 10. |
| SAFE105 | no_recursion |
Flags a function_definition calling itself bare (recurse($n - 1)) or a method_declaration calling itself via $this->walk(...) / self::walk(...) / static::walk(...) inside walk(). A call on another object does not fire. Direct self-recursion only (mutual recursion is out of scope). Enabled by default at warning severity. |
| SAFE301 | global_state |
PHP is this rule's first non-Python registration. PHP has a literal global keyword, so SAFE301 fires on a global $config; statement inside a function - the direct analogue of Python's global declaration. Enabled by default at warning severity. |
| SAFE302 | global_mutation |
Fires on writes to global state. In PHP that is a write through a global-declared name and, additionally, a write to the $GLOBALS[...] superglobal ($GLOBALS['x'] = ...). Reads are clean; only mutations fire. |
| SAFE303 | side_effects_hidden |
Fires when a function with a "pure" name prefix (get / compute / is / validate / etc.) contains an I/O call. Default io_functions_php covers output (print_r / var_dump / printf / fwrite), filesystem (fopen / readfile / file_get_contents / file_put_contents), network (curl_exec / mail), and HTTP side-effects (header / setcookie). |
| SAFE304 | side_effects |
Fires on any function (not name-signalled for I/O) containing an I/O call. The io_functions_php list is kept identical to SAFE303 here (not narrowed as for Go) because the PHP primitives are unambiguous global builtins with no method-name overlap. The io_name_keywords exemption suppresses functions whose names already signal I/O. |
| SAFE309 | dynamic_code_execution |
Structural flag for runtime code evaluation. Default dynamic_exec_calls_php: eval (a language construct that parses as a call), assert (its string form evaluates code), create_function (the legacy runtime function builder), and the call_user_func / call_user_func_array dispatchers. Variable include / require is SAFE801 (taint) territory, not here. Disabled by default. |
| SAFE401 | resource_lifecycle |
PHP has no RAII or with block, so the safe form is a try { } finally { } that closes the handle. Fires on a tracked acquirer (tracked_functions_php: fopen / fsockopen / popen / proc_open / curl_init / opendir / tmpfile) whose handle is not closed in a finally. |
| SAFE501 | unbounded_loops |
Flags while (true) and for (;;) (the empty three-clause header) without an exiting break. Numeric break N levels are resolved: PHP's break 2; exits two enclosing loops / switches at once, so a break 2 inside a nested loop correctly counts as exiting the outer while (true). Conditioned (while ($cond)), for, and foreach loops are bounded shapes and never fire. |
| SAFE601 | missing_assertions |
Counts assertion calls per function. Default assertion_calls_php covers assert plus the PHPUnit family (assertSame / assertEquals / assertTrue / assertNull / assertCount / assertInstanceOf / assertThat / expectException / fail etc.). min_assertions defaults to 1; set 2 to match the paper. Disabled by default. |
| SAFE603 | blanket_suppression |
The headline PHP target is the @ error-suppression operator (@file_get_contents(...)): every use is flagged, since @ silences the analyser and the runtime alike. Also flags bare phpcs:ignore (no sniff named), @phpstan-ignore-line, and @psalm-suppress all. Scoped forms (phpcs:ignore Generic.Files.LineLength, @phpstan-ignore-next-line specific.error) are clean. Disabled by default. |
| SAFE701 | test_existence |
PHP's convention is the PHPUnit <ClassName>Test.php file under tests/. SAFE701 looks for that pair; a *Test.php file is itself a test and is skipped. Disabled by default. |
| SAFE702 | test_coupling |
Same PHPUnit convention: when Foo.php changes, its FooTest.php under tests/ must change too. Test files are exempt from the coupling check. Disabled by default. |
| SAFE801 | tainted_sink |
Models the classic PHP web-taint flow, tracked by analysis/dataflow_php.py (iterative worklists). Sources (sources_php) are the superglobals - reading any key from one ($_GET['id']) yields attacker-controlled data: $_GET / $_POST / $_REQUEST / $_COOKIE / $_SERVER / $_FILES / $_ENV. Sinks (sinks_php): eval / exec / system / shell_exec / passthru / popen / proc_open (command), unserialize (object-injection), query / mysqli_query / pg_query (raw SQL), plus dynamic include / require. Sanitizers (sanitizers_php): intval / floatval (numeric coercion), escapeshellarg / escapeshellcmd (shell neutralisation), plus the generic sanitize / validate / escape / quote. htmlspecialchars is deliberately excluded - it is HTML-context-only (clears XSS, not SQLi / command injection); add it via config if your sinks are HTML-only. Disabled by default. |
| SAFE802 | return_value_ignored |
Fires on a bare call statement whose meaningful return is discarded. Default flagged_calls_php: fwrite / fputs / fclose, unlink / rename / copy / mkdir / rmdir, file_put_contents, mail, session_start - functions whose false-on-failure return signals an error you must check. Disabled by default. |
| SAFE803 | null_dereference |
Fires on a method / property access chained off a call whose name is in nullable_methods_php without a null guard. Default set: array-pointer functions (current / next / prev / end / reset), DateTime::createFromFormat (returns false on bad input), and ORM finders (find / first, which return null when absent). The nullsafe operator ?-> is recognised as the safe form: $repo->find($id)?->name does not fire. Disabled by default. |
Rules not registered for PHP#
| Code | Rule | Why skipped for PHP |
|---|---|---|
| SAFE201 | bare_except |
PHP 7+ catch always carries a type - catch (\Throwable $e) is the typed catch-all, which is auditable rather than a bare-catch hazard. The "swallowed / unlogged catch" spirit is covered by SAFE202 empty_except and SAFE203 logging_on_error, the same treatment as Java. |
| SAFE305 | wide_scope_declaration |
PHP has no var / let / const declaration-keyword distinction at function scope; a variable simply exists where first assigned. There is no narrow-the-declaration hazard to flag. |
Key PHP adaptations#
A few PHP-vs-other-languages shapes the rule engine contends with, worth knowing when reading violations:
- Mixed HTML + PHP.
.phpfiles routinely interleave HTML and<?php ?>blocks. SafeLint parses with the mixed grammar so the HTML arrives as inerttextnodes the rules never match; only the code inside the PHP tags is analysed. A pure-template file with no PHP logic produces no violations. - The
@operator is the most literal "suppress the analyser" construct. SAFE603's headline PHP target is@expr, which silences both the runtime warning and any analyser. Every@use is flagged (Holzmann rule 10). globalkeyword +$GLOBALS. PHP is the first non-Python language to register SAFE301 (global_state) because it has a literalglobalkeyword. SAFE302 additionally flags$GLOBALS[...]superglobal writes.- Numeric
break N. PHP'sbreaktakes an optional level:break 2;exits two enclosing loops / switches. SAFE501 resolves the level when deciding whether awhile (true)has an exiting break. - The nullsafe operator
?->. SAFE803 treats$x?->yas the safe dereference form; only an unguarded->/::off a nullable-returning call fires. - PHPUnit
*Test.phpundertests/. SAFE701 / SAFE702 use the<ClassName>Test.phpconvention rather than a sibling file or_testsuffix.
Known limitation: case-sensitive function-name matching#
PHP function and method names are case-insensitive at runtime (System(...) calls the same builtin as system(...)), but SafeLint matches resolved call names against the configured lists (sinks_php, io_functions_php, dynamic_exec_calls_php, assertion_calls_php, ...) case-sensitively. The shipped defaults are lowercase, so canonically-cased code (the overwhelming norm, and what IDEs / formatters produce) matches as expected; a deliberately mixed-case spelling like SYSTEM($_GET['x']) would currently evade the corresponding rule. Variable names (including the superglobals $_GET / $_POST) are genuinely case-sensitive in PHP and are matched exactly. If your code base mixes the case of builtin calls, add the alternate spellings to the relevant _php config list. (Superglobal variable names are unaffected.)
Configuration#
SafeLint config is read from [tool.safelint] in pyproject.toml (if your PHP project also has one) or from a standalone safelint.toml at the project root. Pure-PHP projects typically prefer the standalone form, which drops the [tool.safelint] prefix.
Per-rule TOML overrides#
Override any per-language config list with the _php suffix. Each example is shown in both forms: [tool.safelint.rules.<rule>] for pyproject.toml and [rules.<rule>] for a standalone safelint.toml.
SAFE303 / SAFE304 side_effects - io_functions_php:
# pyproject.toml
[tool.safelint.rules.side_effects]
io_functions_php = ["var_dump", "printf", "fwrite", "file_put_contents"] # narrower than the default
# safelint.toml
[rules.side_effects]
io_functions_php = ["var_dump", "printf", "fwrite", "file_put_contents"]
SAFE309 dynamic_code_execution - dynamic_exec_calls_php:
# pyproject.toml
[tool.safelint.rules.dynamic_code_execution]
enabled = true
dynamic_exec_calls_php = ["eval", "assert", "create_function", "call_user_func", "call_user_func_array"]
# safelint.toml
[rules.dynamic_code_execution]
enabled = true
dynamic_exec_calls_php = ["eval", "assert", "create_function", "call_user_func", "call_user_func_array"]
SAFE401 resource_lifecycle - tracked_functions_php:
# pyproject.toml
[tool.safelint.rules.resource_lifecycle]
tracked_functions_php = ["fopen", "fsockopen", "popen", "proc_open", "curl_init", "opendir", "tmpfile"]
# safelint.toml
[rules.resource_lifecycle]
tracked_functions_php = ["fopen", "fsockopen", "popen", "proc_open", "curl_init", "opendir", "tmpfile"]
SAFE601 missing_assertions - assertion_calls_php:
# pyproject.toml
[tool.safelint.rules.missing_assertions]
enabled = true
min_assertions = 2
assertion_calls_php = ["assert", "assertSame", "assertEquals", "assertTrue", "assertThat"]
# safelint.toml
[rules.missing_assertions]
enabled = true
min_assertions = 2
assertion_calls_php = ["assert", "assertSame", "assertEquals", "assertTrue", "assertThat"]
SAFE801 tainted_sink - sinks_php / sanitizers_php / sources_php:
# pyproject.toml
[tool.safelint.rules.tainted_sink]
enabled = true # dataflow rules are opt-in
sinks_php = ["eval", "exec", "system", "shell_exec", "unserialize", "query", "mysqli_query"]
sources_php = ["$_GET", "$_POST", "$_REQUEST", "$_COOKIE", "$_SERVER", "$_FILES", "$_ENV"]
sanitizers_php = ["intval", "escapeshellarg", "escapeshellcmd", "htmlspecialchars"] # add htmlspecialchars for HTML-only sinks
# safelint.toml
[rules.tainted_sink]
enabled = true
sinks_php = ["eval", "exec", "system", "shell_exec", "unserialize", "query", "mysqli_query"]
sources_php = ["$_GET", "$_POST", "$_REQUEST", "$_COOKIE", "$_SERVER", "$_FILES", "$_ENV"]
sanitizers_php = ["intval", "escapeshellarg", "escapeshellcmd", "htmlspecialchars"]
SAFE802 return_value_ignored - flagged_calls_php:
# pyproject.toml
[tool.safelint.rules.return_value_ignored]
enabled = true
flagged_calls_php = ["fwrite", "fclose", "unlink", "rename", "file_put_contents", "mail"]
# safelint.toml
[rules.return_value_ignored]
enabled = true
flagged_calls_php = ["fwrite", "fclose", "unlink", "rename", "file_put_contents", "mail"]
SAFE803 null_dereference - nullable_methods_php:
# pyproject.toml
[tool.safelint.rules.null_dereference]
enabled = true
nullable_methods_php = ["current", "next", "end", "reset", "createFromFormat", "find", "first"]
# safelint.toml
[rules.null_dereference]
enabled = true
nullable_methods_php = ["current", "next", "end", "reset", "createFromFormat", "find", "first"]
Enabling the disabled-by-default rules#
The dataflow rules (SAFE801 / SAFE802 / SAFE803), SAFE309, SAFE601, and SAFE603 ship disabled by default. Opt-in via TOML (shown here in pyproject.toml form; the standalone safelint.toml form drops the tool.safelint. prefix):
[tool.safelint.rules.dynamic_code_execution]
enabled = true
[tool.safelint.rules.missing_assertions]
enabled = true
[tool.safelint.rules.blanket_suppression]
enabled = true
[tool.safelint.rules.tainted_sink]
enabled = true
[tool.safelint.rules.return_value_ignored]
enabled = true
[tool.safelint.rules.null_dereference]
enabled = true
Integration with the PHP tool chain#
SafeLint runs alongside the standard PHP tool chain; it doesn't replace any of it. Typical wiring:
- PHP_CodeSniffer (
phpcs) handles coding-standard / formatting checks; safelint doesn't lint style. - PHPStan and Psalm cover a broad surface of type-level and correctness analysis. SafeLint targets the engineering-discipline patterns (function shape, error-handling discipline, global state, dataflow taint) those tools leave alone or treat differently; use them together, they complement.
- Pre-commit: drop into
.pre-commit-config.yaml:
- repo: https://github.com/shelkesays/safelint
rev: v2.6.0 # or whatever the latest tag is
hooks:
- id: safelint
additional_dependencies: ['safelint[php]']
# For a polyglot repo with Python + PHP:
# additional_dependencies: ['safelint[python,php]']
Pre-commit routes .php files via the php filetype tag in types_or (pre-commit's identify library recognises it).
- CI: invoke
safelint check src/ --fail-on warning(or--mode ci) in your build pipeline. Exit code 0 / 1 / 2 maps cleanly to "passed" / "violations found" / "setup error - install hint emitted on stderr". - IDE: any JSON-output-consuming editor plugin (the safelint JSON schema is stable in v2.0.0+) can surface violations inline.
Framework presets#
PHP source is PHP source - the parser and rule logic are framework-agnostic. The rule defaults shift with the framework via [tool.safelint.php] framework = "<name>":
| Framework | When to pick it | What changes |
|---|---|---|
vanilla (default) |
Plain PHP, framework-free libraries | Stdlib-only defaults (the lists in the rules table above). The SAFE905-907 framework rules are disabled. |
laravel |
Laravel apps | Adds the raw-SQL query-builder methods whereRaw / orderByRaw / havingRaw / selectRaw / unprepared to the SAFE801 PHP sinks. Enables SAFE905-907: Eloquent $guarded = [] mass-assignment (SAFE906), $request->all() / bare $request->input() without validate() (SAFE907; a targeted $request->input('field') read is not flagged), and a 'debug' => true config-array entry (SAFE905). |
# safelint.toml (standalone) - no [tool.safelint] wrapper
[php]
framework = "laravel"
# Or, in pyproject.toml:
[tool.safelint.php]
framework = "laravel"
Explicit per-rule TOML config still wins over the preset; [tool.safelint.rules.tainted_sink] sinks_php = [...] overrides whatever the preset planted. The default framework is vanilla, so existing users with no [php] config see no behaviour change. An unknown framework name surfaces a safelint: warning: on stderr and falls back to vanilla.
.env files are not parsed, so SAFE905 detects a debug flag only where it appears in PHP code (a config([...]) array or a returned config array), not in APP_DEBUG=true env entries - a documented limit. Symfony / WordPress presets are not shipped; track them via GitHub issues.