psalm/plugin-laravel
Psalm plugin for Laravel that adds deep framework-aware static analysis plus taint-based security scanning. Detects SQL injection, XSS, SSRF, shell injection, file traversal, and open redirects by tracking user input flows across functions and services.
This guide covers how to write and review taint analysis stubs for psalm-plugin-laravel.
For Psalm's upstream taint analysis documentation, see:
[@psalm-taint-source](https://github.com/psalm-taint-source), [@psalm-taint-sink](https://github.com/psalm-taint-sink), [@psalm-taint-escape](https://github.com/psalm-taint-escape), [@psalm-taint-unescape](https://github.com/psalm-taint-unescape), [@psalm-taint-specialize](https://github.com/psalm-taint-specialize), [@psalm-flow](https://github.com/psalm-flow)[@psalm-taint-escape](https://github.com/psalm-taint-escape), [@psalm-taint-specialize](https://github.com/psalm-taint-specialize), ignoring files[@psalm-taint-unescape](https://github.com/psalm-taint-unescape)[@psalm-taint-source](https://github.com/psalm-taint-source) annotation and plugin API[@psalm-taint-sink](https://github.com/psalm-taint-sink) annotation[@psalm-flow](https://github.com/psalm-flow) proxy and return hintsTaint annotations live in stubs/common/ alongside type stubs, organized by Laravel namespace.
Psalm 7 runs taint analysis by default ($run_taint_analysis = true), so there is no need for a separate directory.
There are six taint-related annotations. The first four are the ones you'll use most in stubs:
| Annotation | Purpose | Needs [@psalm-flow](https://github.com/psalm-flow)? |
|---|---|---|
[@psalm-taint-source](https://github.com/psalm-taint-source) <kind> |
Marks return value as producing tainted data | No -- sources create new taint |
[@psalm-taint-sink](https://github.com/psalm-taint-sink) <kind> <$param> |
Marks a parameter as dangerous if tainted | No -- sinks are endpoints |
[@psalm-taint-escape](https://github.com/psalm-taint-escape) <kind> |
Removes a specific taint kind from the return value | Yes -- see critical rule below |
[@psalm-flow](https://github.com/psalm-flow) (<$params>) -> return |
Declares that taint propagates from params to return | N/A -- this IS the flow declaration |
[@psalm-taint-unescape](https://github.com/psalm-taint-unescape) <kind> |
Re-adds a taint kind (reverses an earlier escape) | Yes -- same pattern as escape |
[@psalm-taint-specialize](https://github.com/psalm-taint-specialize) |
Tracks taints per call-site instead of globally | No |
[@psalm-taint-escape](https://github.com/psalm-taint-escape) with [@psalm-flow](https://github.com/psalm-flow)[@psalm-taint-escape](https://github.com/psalm-taint-escape) alone makes the return value fully untainted -- it drops ALL taint kinds, not just the one specified. This creates dangerous false negatives.
To remove only specific taint kinds while preserving others, you must add [@psalm-flow](https://github.com/psalm-flow):
// WRONG: drops ALL taints (html, sql, shell, etc.)
// e($userInput) used in a SQL query would NOT trigger TaintedSql
/**
* [@psalm-taint-escape](https://github.com/psalm-taint-escape) html
* [@psalm-taint-escape](https://github.com/psalm-taint-escape) has_quotes
*/
function e($value, $doubleEncode = true) {}
// CORRECT: drops only html + has_quotes, other taints flow through
// e($userInput) used in a SQL query WILL trigger TaintedSql
/**
* [@psalm-taint-escape](https://github.com/psalm-taint-escape) html
* [@psalm-taint-escape](https://github.com/psalm-taint-escape) has_quotes
* [@psalm-flow](https://github.com/psalm-flow) ($value) -> return
*/
function e($value, $doubleEncode = true) {}
The same rule applies to [@psalm-taint-unescape](https://github.com/psalm-taint-unescape) -- always pair it with [@psalm-flow](https://github.com/psalm-flow).
Psalm's own stubs follow this pattern -- see urlencode()/strip_tags() in vendor/vimeo/psalm/stubs/CoreGenericFunctions.phpstub.
[@psalm-flow](https://github.com/psalm-flow) is NOT neededSinks don't need [@psalm-flow](https://github.com/psalm-flow) because they are endpoints -- they consume tainted data, they don't produce output:
/**
* [@psalm-taint-sink](https://github.com/psalm-taint-sink) sql $query
*/
public function unprepared($query) {}
Sources don't need [@psalm-flow](https://github.com/psalm-flow) because they create new taint on the return value, not flow from input:
/**
* [@psalm-taint-source](https://github.com/psalm-taint-source) input
*/
public function input($key = null, $default = null) {}
Exception -- sink-only escapes: If a function's return value is never used for taint-sensitive operations (e.g., Hash::make() returns a hash that's safe by nature), [@psalm-taint-escape](https://github.com/psalm-taint-escape) without [@psalm-flow](https://github.com/psalm-flow) is acceptable because there's no meaningful taint to preserve on the return value.
All taint kind names are defined in Psalm\Type\TaintKind::TAINT_NAMES. These are the strings you use in annotations.
| Kind | Attack vector | Example sink | Example escape |
|---|---|---|---|
html |
XSS via HTML injection | echo, Response::make() |
e(), htmlspecialchars() |
has_quotes |
Attribute injection via unquoted strings | echo inside HTML attributes |
e(), urlencode() |
sql |
SQL injection | Connection::unprepared() |
Connection::escape(), parameterized queries |
shell |
Command injection | Process::run() |
escapeshellarg() |
ssrf |
Server-side request forgery | Http::get($url) |
-- |
file |
Path traversal | Filesystem::get(), response()->download() |
-- |
user_secret |
Password/token exposure in logs or output | echo, log sinks |
Hash::make(), Encrypter::encrypt() |
system_secret |
Internal secret exposure | echo, log sinks |
Hash::make(), Encrypter::encrypt() |
| Kind | Constant | Description |
|---|---|---|
callable |
INPUT_CALLABLE |
User-controlled callable strings |
unserialize |
INPUT_UNSERIALIZE |
Strings passed to unserialize() |
include |
INPUT_INCLUDE |
Paths passed to include/require |
eval |
INPUT_EVAL |
Strings passed to eval() |
ldap |
INPUT_LDAP |
LDAP DN or filter strings |
sql |
INPUT_SQL |
SQL query strings |
html |
INPUT_HTML |
Strings that could contain HTML/JS |
has_quotes |
INPUT_HAS_QUOTES |
Strings with unescaped quotes |
shell |
INPUT_SHELL |
Shell command strings |
ssrf |
INPUT_SSRF |
URLs passed to HTTP clients |
file |
INPUT_FILE |
Filesystem paths |
cookie |
INPUT_COOKIE |
HTTP cookie values |
header |
INPUT_HEADER |
HTTP header values |
xpath |
INPUT_XPATH |
XPath query strings |
sleep |
INPUT_SLEEP |
Values passed to sleep() (DoS) |
extract |
INPUT_EXTRACT |
Values passed to extract() |
user_secret |
USER_SECRET |
User-supplied secrets (passwords, tokens) |
system_secret |
SYSTEM_SECRET |
System secrets (API keys, encryption keys) |
input |
ALL_INPUT |
Alias: all input-related kinds combined (excludes secrets) |
tainted |
ALL_INPUT |
Alias: same as input |
input_except_sleep |
ALL_INPUT & ~INPUT_SLEEP |
All input kinds except sleep (used by filter_var()) |
Mark methods that return user-controlled data. In Laravel, the primary sources are on Request:
/**
* [@psalm-taint-source](https://github.com/psalm-taint-source) input
*/
public function input($key = null, $default = null) {}
Mark parameters where tainted data is dangerous. Always specify which parameter receives tainted data:
/**
* [@psalm-taint-sink](https://github.com/psalm-taint-sink) sql $query
*/
public function select($query, $bindings = [], $useReadPdo = true) {}
Multiple parameters can be sinks:
/**
* [@psalm-taint-sink](https://github.com/psalm-taint-sink) html $callback
* [@psalm-taint-sink](https://github.com/psalm-taint-sink) html $data
*/
public function jsonp($callback, $data = []) {}
Mark functions that sanitize specific taint kinds. Always pair with [@psalm-flow](https://github.com/psalm-flow):
/**
* [@psalm-taint-escape](https://github.com/psalm-taint-escape) html
* [@psalm-taint-escape](https://github.com/psalm-taint-escape) has_quotes
* [@psalm-flow](https://github.com/psalm-flow) ($value) -> return
*/
function e($value, $doubleEncode = true) {}
Mark functions that reverse sanitization, re-introducing taint. Used for decrypt, decode, etc.:
/**
* [@psalm-taint-unescape](https://github.com/psalm-taint-unescape) user_secret
* [@psalm-taint-unescape](https://github.com/psalm-taint-unescape) system_secret
* [@psalm-flow](https://github.com/psalm-flow) ($payload) -> return
*/
public function decrypt($payload, $unserialize = true) {}
When a function passes taint through without escaping or sinking, use [@psalm-flow](https://github.com/psalm-flow) alone. This is useful for wrapper functions where Psalm can't automatically trace the data flow:
/**
* [@psalm-flow](https://github.com/psalm-flow) ($value, $items) -> return
*/
function inputOutputHandler(string $value, string ...$items): string {}
[@psalm-flow](https://github.com/psalm-flow)$this is not supported as a flow source[@psalm-flow](https://github.com/psalm-flow) ($this) -> return does not work. Psalm's flow parser only matches named method parameters — $this is never in that list. The annotation is silently ignored with no error.
This means you cannot declare taint flow from an object instance to a method's return value via stubs. For fluent/builder classes like Stringable, taint entering via Str::of($tainted) will not automatically propagate through chained methods like ->trim()->lower()->toString().
Workarounds:
Str::of(), str()) with [@psalm-flow](https://github.com/psalm-flow) ($param) -> return so the returned object carries taintappend($values)) with [@psalm-flow](https://github.com/psalm-flow) ($values) -> return$this → return propagation, a handler using AfterMethodCallAnalysisInterface is needed (not yet implemented)[@psalm-taint-specialize](https://github.com/psalm-taint-specialize)When a function has [@psalm-flow](https://github.com/psalm-flow) ($param) -> return without [@psalm-taint-specialize](https://github.com/psalm-taint-specialize), Psalm merges taint from all call sites globally. This means one tainted call site poisons all others:
// WITHOUT [@psalm-taint-specialize](https://github.com/psalm-taint-specialize):
// Str::of($request->input('name')) at line 10 taints ALL Str::of() calls,
// so Str::of('safe literal') at line 20 is falsely reported as tainted.
// CORRECT: pair both annotations on pure flow-through factories
/**
* [@psalm-taint-specialize](https://github.com/psalm-taint-specialize)
* [@psalm-flow](https://github.com/psalm-flow) ($string) -> return
*/
public static function of($string) {}
This differs from escape functions like e(), where [@psalm-taint-specialize](https://github.com/psalm-taint-specialize) is not needed because the escape annotation removes the dangerous taint kind regardless of call site. Pure flow-through functions (no escape/unescape) must always pair [@psalm-taint-specialize](https://github.com/psalm-taint-specialize) with [@psalm-flow](https://github.com/psalm-flow).
vendor/laravel/framework/[@psalm-taint-escape](https://github.com/psalm-taint-escape) or [@psalm-taint-unescape](https://github.com/psalm-taint-unescape): always add [@psalm-flow](https://github.com/psalm-flow) to preserve other taint kinds (unless the return value's other taints are truly irrelevant)stubs/common/ under a path matching the Laravel namespaceThe project's own psalm.xml cannot test taint stubs (the plugin can't analyze itself). Create a separate test project:
mkdir -p /tmp/taint-test/app
cat > /tmp/taint-test/psalm.xml << 'XMLEOF'
<?xml version="1.0"?>
<psalm errorLevel="1" findUnusedCode="false"
xmlns="https://getpsalm.org/schema/config">
<projectFiles><directory name="app" /></projectFiles>
<plugins><pluginClass class="Psalm\LaravelPlugin\Plugin"/></plugins>
</psalm>
XMLEOF
# Write test PHP in /tmp/taint-test/app/Test.php, then:
cd /tmp/taint-test && /path/to/vendor/bin/psalm --no-cache
Tip: Use --dump-taint-graph=taints.dot to visualize taint flow and debug unexpected results. See Debugging the taint graph.
Facade static calls (DB::unprepared(...)) may not propagate taint because __callStatic loses taint context. The generated alias stubs (class X extends Y {}) don't carry taint annotations. Calling the underlying class directly (DB::connection()->unprepared(...)) works correctly.
How can I help you explore Laravel packages today?