psalm/plugin-laravel
Laravel Psalm plugin for deep static analysis plus taint-based security scanning. Detects SQL injection, XSS, SSRF, shell injection, path traversal, and open redirects by tracking user input through Laravel code without running it.
## Getting Started
### Minimal Steps
1. **Installation**:
```bash
composer config minimum-stability dev
composer require --dev psalm/plugin-laravel:^4.8
Initialize Laravel-tailored config:
./vendor/bin/psalm-laravel init --level 4
(Start with --level 4 for a balanced strictness; adjust later.)
First analysis:
./vendor/bin/psalm-laravel analyze
(Security taint analysis runs automatically—no extra flags needed.)
Baseline existing issues (for legacy projects):
./vendor/bin/psalm --set-baseline=psalm-baseline.xml
TaintedSql, TaintedXss, and TaintedShell errors in psalm.xml output.MissingReturnType, MixedAssignment, and UndefinedClass in Laravel-specific code (e.g., facades, Eloquent)../vendor/bin/psalm-laravel analyze --stats to identify the most common issue types.Catch SQL injection in a controller:
// app/Http/Controllers/SearchController.php
public function search(Request $request) {
$column = $request->input('sort'); // Tainted source
return User::where('name', 'John')
->orderBy($column) // Psalm flags: TaintedSql
->get();
}
Fix: Validate/sanitize $column or use a whitelist:
$allowedColumns = ['name', 'created_at'];
$column = in_array($request->input('sort'), $allowedColumns)
? $request->input('sort')
: 'name';
New Use Case: DateTimeInterface Support in whereDate()
// Previously would trigger ImplicitToStringCast error
Post::query()->whereDate('created_at', CarbonImmutable::now()); // Now works!
Daily Security Scan:
./vendor/bin/psalm-laravel analyze --output-format=text --report=security.sarif
security.sarif into GitHub Security tab (requires GHAS for private repos).--threads=4 for faster analysis on CI (GitHub Actions defaults to 1 thread).CI Integration (GitHub Actions):
# .github/workflows/psalm.yml
jobs:
psalm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: igbinary
- run: composer install --prefer-dist
- run: ./vendor/bin/psalm --output-format=github --threads=4
git-restore-mtime-action to preserve file timestamps for Psalm cache:
- uses: chetan/git-restore-mtime-action@v2
Incremental Adoption:
--level 4 (default) and gradually lower to 1 (strictest).--ignore-errors to suppress specific issues temporarily:
./vendor/bin/psalm --ignore-errors=MissingReturnType --ignore-errors=TaintedSql
Route::, Cache::). No manual @mixin needed.php artisan schema:dump or migrations. Ensure:
// config/psalm.php
'schema_dumps' => [
database_path('schema_dump.sql'),
],
psalm.xml:
<psalm>
<plugins>
<pluginClass class="Psalm\LaravelPlugin\Plugin">
<param name="custom_taint_sources">
<array>
<string>App\Services\UntrustedInput::get()</string>
</array>
</param>
</pluginClass>
</plugins>
</psalm>
| Pattern | Example |
|---|---|
| Taint Propagation | User input → Helper → Controller → Query → Psalm flags TaintedSql. |
| Facade Type Safety | Route::get() returns Illuminate\Routing\Route (not mixed). |
| Collection Methods | Collection::where() infers return type based on model properties. |
| Middleware Checks | Psalm validates Handle method signatures in middleware. |
| DateTimeInterface | whereDate() now accepts CarbonImmutable/DateTimeImmutable in two-arg form. |
False Positives in Taint Analysis:
@psalm-suppress Tainted* or annotate validation:
/** @psalm-suppress TaintedSql */
$column = $request->validate(['sort' => 'string|in:name,created_at'])->input('sort');
psalm.xml:
<psalm>
<plugins>
<pluginClass class="Psalm\LaravelPlugin\Plugin">
<param name="allowed_tainted_sinks">
<array>
<string>App\Models\User::where()</string>
</array>
</param>
</pluginClass>
</plugins>
</psalm>
Schema Inference Failures:
php artisan schema:dump and update psalm.xml:
<psalm>
<schema_dumps>
<file>database/schema_dump.sql</file>
</schema_dumps>
</psalm>
/** @property string $name */
class User extends Model {}
CI Cache Invalidation:
- uses: chetan/git-restore-mtime-action@v2
psalm.xml, psalm-baseline.xml, and composer.lock.Performance on Large Codebases:
--threads=8 (adjust based on CI runner cores).<psalm>
<projectFiles>
<exclude-name>tests/**</exclude-name>
<exclude-name>vendor/**</exclude-name>
</projectFiles>
</psalm>
Inspect Taint Flow:
./vendor/bin/psalm --taint-analysis --taint-flow=debug
(Shows how data propagates between sources/sinks.)
Verify Facade Stubs:
./vendor/bin/psalm --generate-stubs --output-dir=stubs
(Check stubs/Illuminate/Foundation/Application.php for generated aliases.)
Isolate Issues:
./vendor/bin/psalm --ignore-errors=* --show-info=true
(Lists all issues with file/line context.)
Custom Checks:
psalm.xml:
<psalm>
<file_list>
<file>app/Rules/CustomRule.php</file>
</file_list>
</psalm>
authorize() in controllers:
// app/Rules/AuthorizeRule.php
class AuthorizeRule extends CustomRule {
public function apply(Call $call): void {
if ($call->getFunction()->getName() === 'get' &&
!$call->getParentClass()?->isSubclassOf('Illuminate\Routing\Controller')) {
$this->reportError('Controllers must use authorize().');
}
}
}
Override Taint Behavior:
Psalm\LaravelPlugin\Plugin to add custom sources/sinks:
// app/Plugins/CustomTaintPlugin.php
use Psalm\Plugin\PluginEntryPointInterface;
class CustomTaintPlugin implements PluginEntryPointInterface {
public function getPluginName(): string { return 'custom_taint'; }
public function register(ContainerInterface $container): void {
$container->getDefinition
How can I help you explore Laravel packages today?