thecodingmachine/phpstan-safe-rule
PHPStan rule that detects calls to unsafe core PHP functions and suggests the thecodingmachine/safe variants, which throw exceptions instead of returning false on error. Helps catch unsafe function usage during static analysis.
This rule is a companion to thecodingmachine/safe — it is worthless on its own. Install them as a pair, in the same commit:
composer require thecodingmachine/safe # runtime: your app calls Safe\* functions
composer require --dev thecodingmachine/phpstan-safe-rule
Safe belongs in require (production code imports Safe\ functions); the rule is analyser-only and must stay in require-dev.
If you already have phpstan/extension-installer, the rule's extension.neon is picked up automatically — no config change needed. Otherwise register it explicitly in phpstan.neon / phpstan.neon.dist:
includes:
- vendor/larastan/larastan/extension.neon
- vendor/thecodingmachine/phpstan-safe-rule/extension.neon
parameters:
level: 6
paths:
- app
- routes
Requirements as of v1.4.x: PHP 8.1+ and PHPStan 2.x (PHPStan 1.x support was dropped in v1.3/v1.4 — if you're still pinned to PHPStan 1, stay on v1.2 and accept no further fixes).
Then run the analyser you already run:
vendor/bin/phpstan analyse --memory-limit=1G
You'll get findings like "Function file_get_contents is unsafe, use Safe\file_get_contents instead" pointing at each call site. First thing to do after the first run: verify the gate actually fires. Deliberately add $x = json_decode($s); to a scratch file and confirm it's flagged. A mis-ordered/missing includes: produces a silent green build that looks identical to "codebase is clean" — the single most dangerous failure mode of this package.
First real use case: run it on app/ only, generate a baseline, and treat the baseline as your migration backlog (see below).
Baseline-first rollout on a legacy Laravel app. Never try to fix thousands of findings in one PR. Generate a baseline, commit it, and gate new code only:
vendor/bin/phpstan analyse --generate-baseline
git add phpstan-baseline.neon
includes:
- phpstan-baseline.neon
- vendor/thecodingmachine/phpstan-safe-rule/extension.neon
Then burn down the baseline per module: grep -c 'safe' phpstan-baseline.neon per path gives you a countable backlog ("checkout service: 14 unsafe calls, reporting jobs: 31"), which is a far better sprint ticket than "improve error handling".
Call the Safe variants with function imports. Inside namespaced Laravel code, an unqualified file_get_contents() is a global fallback call and gets flagged. Import explicitly — this is also what reviewers should see in diffs:
use function Safe\file_get_contents;
use function Safe\json_decode;
use function Safe\preg_match;
public function import(string $path): array
{
$raw = file_get_contents($path); // throws instead of returning false
$data = json_decode($raw, true); // throws on malformed JSON
if (preg_match('/^v(\d+)/', $data['tag'], $m) === 1) { ... }
return $data;
}
Use JSON_THROW_ON_ERROR as the sanctioned opt-out. The rule already understands json_decode($s, true, 512, JSON_THROW_ON_ERROR) and the named-argument form (flags: JSON_THROW_ON_ERROR, supported since v1.4.1). That's often a smaller diff than switching to Safe\json_decode, and it keeps native error semantics. Pick one convention per codebase so the static analysis doesn't flip-flop.
Exploit the bundled type inference instead of casting. The extension ships dynamic return types for Safe\json_decode and Safe\preg_replace_callback, and mirrors PHPStan's preg_match special-casing so $matches gets typed from a literal pattern (v1.4.0+). That means:
use function Safe\preg_match;
if (preg_match('/(?<id>\d+)/', $line, $m) === 1) {
$id = $m['id']; // string — no cast, no isset dance
}
This is the main reason to prefer Safe\ over hand-rolled try/catch wrappers: you keep upstream inference. It only works with literal patterns — a dynamically built regex degrades $matches back to a generic ?array.
Replace new DateTime / new DateTimeImmutable (detected since v1.0) with the Safe\ variants when parsing untrusted input — Laravel's Carbon::parse() ultimately sits on the same behaviour, so treat form/API date strings as the priority targets.
Gate changed files only, in a pre-commit hook — the pragmatic middle ground before the baseline is empty:
vendor/bin/phpstan analyse --no-progress $(git diff --name-only --cached --diff-filter=ACM -- '*.php')
Suppressions carry an owner. Use identifier-scoped ignores rather than path-wide ones, and count them in CI:
parameters:
ignoreErrors:
- identifier: <the identifier shown in the error message>
path: app/Legacy/CsvImporter.php
count: 2
Run --error-format=json once to see the exact identifier string; the identifier was fixed in v1.4.0, so anything written against older versions may not match.
includes:, a phpstan.neon overriding phpstan.neon.dist, or a stale result cache all mean zero findings and a false sense of security. Add an intentional unsafe call in a CI smoke test (or a tests/static-analysis/fixtures path analysed in a separate job) so the pipeline proves the rule is loaded.phpstan/phpstan and this package, and upgrade them together.Storage::get(), File::get(), Http::get(), Cache::get() internally call file_get_contents/fopen/json_decode inside vendor code and will never be flagged. The lever is your own service layer: funnel I/O through one class whose methods you do analyse.strlen(...)-style callables landed in v1.4.1 and is decent, but dynamically constructed callables, call_user_func with string function names, and variadic spreads still produce noise. Budget a named owner for triage — one round of "just mute the rule globally" and the whole investment evaporates.vendor/bin/phpstan clear-result-cache. Stale caches after config or dependency changes are the usual cause of "it worked on my machine".--generate-baseline silently absorbs new violations into the ignore file, defeating the gate. Keep reportUnmatchedIgnoredErrors: true, prune entries when you fix them, and review baseline diffs in code review as if they were source.Safe\ functions are plain global-namespace functions — they are not autoloaded classes, so no facades, no DI, no mocking in tests. In unit tests that expect the throwing behaviour, assert on the exception (Safe\Exceptions\FilesystemException, JsonException, PcreException), not on false returns you'd previously have asserted.vendor/bin/phpstan analyse -v --debug shows which files the extension processed; --error-format=json gives you the identifier and message for scripting the burn-down.How can I help you explore Laravel packages today?