This is a PHPStan extension, not a runtime package — nothing ships to production. Treat it as a lint rule that runs in CI and pre-commit, alongside Pint and Larastan.
composer require --dev rajmundtoth0/phpstan-forbidden
Add it to your existing phpstan.neon (you almost certainly already have Larastan wired up in a Laravel app):
includes:
- vendor/larastan/larastan/extension.neon
- vendor/rajmundtoth0/phpstan-forbidden/extension.neon # verify filename in the package README
parameters:
level: 6
paths:
- app
- routes
- database
Then confirm the rule is actually loaded before you trust it — write a deliberate violation and check it fails:
# temporarily add dd('x'); to a controller
vendor/bin/phpstan analyse --debug app/Http/Controllers
Where to look first:
vendor/rajmundtoth0/phpstan-forbidden/extension.neon — tells you the exact parameter name the rule reads and which node types it hooks (FuncCall, StaticCall, MethodCall, ConstFetch, New_).src/ — read it. With 7 stars and 0 dependents, the source is the documentation, and it is short.vendor/spaze/phpstan-disallowed-calls — worth reading as the mature reference implementation so you know what "should" exist vs. what this package actually covers.First use case: ban the debugging helpers and env() outside config/. That single rule pays for itself the first time someone commits a dd().
parameters:
forbidden:
functions:
- dd
- dump
- var_dump
- ray
If your ban list needs path awareness (allow env() in config/, ban it everywhere else), set that up now — retrofitting path scoping across a large ban list is the annoying part.
phpstan.neon.distphpstan.neon.dist is committed and CI reads it; phpstan.neon is the local escape hatch. Ban lists are team policy, so they belong in the .dist file. Local developers who need to temporarily unban something override in phpstan.neon without polluting the shared config.
PHPStan has no per-path rule configuration, but includes gives you the same effect as long as you run analysis in separate invocations:
vendor/bin/phpstan analyse app routes # strict ban set
vendor/bin/phpstan analyse config # env() allowed here
vendor/bin/phpstan analyse tests database # dd() allowed in tests
Create phpstan-app.neon, phpstan-config.neon, etc., each including the base file and adding its own forbidden list. Wire them into composer:
"scripts": {
"lint:static": [
"@php vendor/bin/phpstan analyse -c phpstan-app.neon",
"@php vendor/bin/phpstan analyse -c phpstan-config.neon",
"@php vendor/bin/phpstan analyse -c phpstan-tests.neon"
]
}
Note that each invocation is a separate PHPStan process, so expect a few extra seconds of container boot per run. For a monorepo this is fine; for a small app consider consolidating to two configs (app vs. everything-else).
dd()The high-value targets in a typical Laravel codebase:
dd, dump, ray, var_dump, sleep, exit, dieenv() in app/ — forces everything through config(), which is what makes config:cache work in productionModel::unguard(), Model::reguard()DB::raw(), DB::statement(), DB::unprepared() — ban outright or confine to app/Repositories with a narrow path-scoped configAuth::loginUsingId(), auth()->loginUsingId() outside testsapp(SomeService::class) inside controllers — pushes the team toward constructor injectionRedis::flushall(), Artisan::call('migrate:fresh')Start with the debug helpers (zero false positives), then add env() (path-scoped), then the opinionated ones like DB::raw() (expect pushback, expect legitimate exceptions).
The most effective workflow is incremental and one-directional:
ignoreErrors entries for the remaining call sites with count: set to the current number.count:.count: hits zero, delete the ignoreError entry.parameters:
ignoreErrors:
-
message: '#Forbidden function LegacyHelper::normalize#'
path: app/Legacy/*
count: 12
count: is the key detail — it makes the ignore a ratchet. New violations fail immediately; the number can only go down.
ignoreErrors, never with a blanket path exclusionexcludePaths is a blunt instrument — it disables all PHPStan rules for that directory, including null-safety and type checks. If you need to allow DB::raw() in app/Repositories/, use a path-scoped ignoreErrors entry targeting the specific forbidden message, so the rest of PHPStan still runs.
Add PHPStan to your pipeline so a banned call can't merge:
- run: composer lint:static
Commit hooks (Husky / captainhook) are a nice fast loop, but treat CI as the authority — hooks are skippable and drift between machines. Also make sure the PHPStan result cache isn't masking a newly added ban: bump a cache key or run --clear-result-cache in CI if your ban list changes frequently, since stale cache can suppress changed-config errors.
A one-line Pest/PHPUnit test that shells out to PHPStan against a fixture file containing dd() and asserts a non-zero exit code is cheap insurance. It catches the failure mode where the extension silently stops loading after a phpstan.neon edit or a package major-version bump — which is otherwise indistinguishable from "no violations found."
It's static analysis only — it stops nothing at runtime. call_user_func('dd'), $fn = 'dd'; $fn(), (new $class)->$method(), and variable functions generally aren't resolved to a static name, so they sail straight past. If a ban is genuinely security-relevant (DB::raw() with user input), pair it with an architecture test or a runtime guard; don't treat the ban list as a control.
Larastan is a prerequisite for anything method-chain based. Without larastan/larastan, PHPStan can't resolve Facade proxy types or dynamic model methods, so banning DB::raw() or Auth::loginUsingId() may produce zero hits — silently. Verify every new ban with a deliberate violation before trusting it.
Never let these errors into a baseline. Running phpstan --generate-baseline after adding bans will happily swallow all of them into phpstan-baseline.neon, and you'll believe the bans are enforced when they aren't. If you regenerate a baseline for other reasons, re-check that forbidden-node errors didn't get swept up. reportUnmatchedIgnoredErrors: true (the default — keep it) helps here.
@phpstan-ignore comments are the hole in the fence. Anyone can silence a banned call inline. PHPStan 1.11+ warns on unused ignores; consider setting reportUnusedIgnoredErrors: true and reviewing @phpstan-ignore additions in code review the same way you'd review a // eslint-disable.
Exclude generated code from analysis. _ide_helper.php, _ide_helper_models.php, bootstrap/cache/*, and storage/framework/* routinely appear in paths and generate noise. Also remember vendor code is never analysed by default — bans apply to your source only, so you can't forbid a package's internals this way.
Pin the version and isolate the config. With 7 stars, a single maintainer, and a small dependents count, treat the config surface as unstable across releases. Two mitigations: (a) "rajmundtoth0/phpstan-forbidden": "1.0.*" rather than ^1.0, and (b) put all forbidden: parameters in one small phpstan-forbidden.neon that your main configs include. Swapping to spaze/phpstan-disallowed-calls (which has broader node coverage, active maintenance, and a larger user base) then becomes a one-file change instead of a hunt through every config. Honestly, for anything beyond a handful of function bans, evaluate spaze/phpstan-disallowed-calls first — it's the more battle-tested option and the migration cost is lower now than later.
The parameter key may not match what you expect. Extension parameter names have shifted in this package's history and the README is the only contract. If your forbidden: block produces no errors, the first debugging step is not "is my list wrong" — it's vendor/bin/phpstan analyse --debug and inspecting the loaded config with -vvv to confirm the parameter is actually being read rather than silently ignored. An unknown parameter in a neon file is not an error by default.
Performance is a non-issue, but config sprawl isn't. The rule is a few array lookups per node; you won't measure it. What you will feel is three or four overlapping phpstan-*.neon files where a ban added to one isn't in the others. Keep the base list in a single included file and only layer path-specific additions on top.
Extension point if coverage falls short: write a plain PHPStan Rule in app/Support/PHPStan/ implementing PHPStan\Rules\Rule<FuncCall> and register it in phpstan.neon under services:. It's ~30 lines, needs no package, and gives you full control over the message and the nodeType — often the right answer for one very specific ban that the package doesn't model.
How can I help you explore Laravel packages today?