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.
__call / __callStatic)How Laravel uses PHP's magic methods to proxy calls across layers, and the exact resolution order PHP follows.
When you call $obj->method(), PHP resolves in this order:
1. Own methods + trait methods (traits are flattened into the class at compile time, so they behave as if declared directly on the class. Last trait wins on conflict.)
2. Inherited methods (parent classes, walking up the chain)
3. __call($name, $arguments) <-- magic fallback
For static calls Class::method():
1. Own static methods + trait static methods (same flattening)
2. Inherited static methods
3. __callStatic($name, $arguments) <-- magic fallback
Key: __call/__callStatic only fire when the method is NOT found through steps 1-2.
Important: __call is itself a regular method — it follows the same own → parent → trait
resolution. There is only ever one __call that fires; PHP does not chain them. If both a
parent class and a trait define __call, the conflict must be resolved explicitly. This is why
Laravel uses the alias pattern:
class Relation {
use Macroable {
__call as macroCall; // rename trait's __call to avoid conflict
}
// Own __call — the ONLY one that fires
public function __call($method, $params)
{
// Manually delegate to the trait's version for macros
if (static::hasMacro($method)) {
return $this->macroCall($method, $params);
}
// Then do its own forwarding
return $this->forwardDecoratedCallTo($this->query, $method, $params);
}
}
class HasMany extends Relation {
// No own __call → inherits Relation::__call
}
The same pattern is used by Relation, Query\Builder, and other classes that
combine Macroable with custom __call forwarding logic. Note: Eloquent\Builder does NOT
use the Macroable trait — it has its own independent two-tier macro system (local + global).
forwardDecoratedCallTo pattern)Class: Illuminate\Database\Eloquent\Relations\Relation
// Relation::__call()
public function __call($method, $parameters)
{
// 1. Check macros first
if (static::hasMacro($method)) {
return $this->macroCall($method, $parameters);
}
// 2. Forward to the underlying Eloquent Builder
// forwardDecoratedCallTo returns $this when the Builder returns itself
return $this->forwardDecoratedCallTo($this->query, $method, $parameters);
}
// Exception: MorphTo overrides __call with two distinct paths:
// - On success: buffers 5 specific methods (select, selectRaw, selectSub,
// addSelect, withoutGlobalScopes) for replay on the resolved related model
// - On BadMethodCallException: buffers the failing call unconditionally
// (assumed to be a macro/scope on the actual related model type)
// This is the only Relation subclass with materially different __call behavior.
forwardDecoratedCallTo behavior (from ForwardsCalls trait, simplified):
// Simplified — actual code validates error message against get_class($object)
// and uses named capture groups. Only "method not found" errors are rewritten;
// all other errors are rethrown as-is.
protected function forwardCallTo($object, $method, $parameters)
{
try {
return $object->{$method}(...$parameters);
} catch (Error|BadMethodCallException $e) {
// Rethrows with the CALLER's class name in the error message
static::throwBadMethodCallException($method); // returns never
}
}
// "Decorated" refers to the Decorator pattern — the Relation wraps (decorates) the Builder.
// When the Builder returns itself, the decorator swaps it for $this so the caller
// stays in the Relation layer instead of dropping down to the Builder.
protected function forwardDecoratedCallTo($object, $method, $parameters)
{
$result = $this->forwardCallTo($object, $method, $parameters);
return $result === $object ? $this : $result;
}
Call chain (PHP runtime resolution, not Psalm's — Psalm uses declaring_method_ids + @mixin instead):
$post->comments()->where('approved', true)
│
├─ comments() returns HasMany (a Relation)
│
└─ where() on HasMany:
1. Not declared on HasMany ✗
2. Not declared on HasOneOrMany ✗
3. Not declared on Relation ✗
4. __call fires →
forwardDecoratedCallTo($this->query, 'where', [...])
→ Builder::where() returns Builder
→ $result === $object? yes → return $this (the HasMany)
Result: HasMany (not Builder)
Psalm challenge: Psalm resolves where() via [@mixin](https://github.com/mixin) Builder on Relation. The resolved method returns Builder, not the Relation. The plugin solves this with MethodForwardingHandler using interceptMixin=true: the handler registers for Builder (the mixin target), detects when the original caller was a Relation, and returns the concrete Relation type with template params instead. For methods declared in Relation stubs, Psalm finds them in declaring_method_ids before reaching the mixin.
Class: Illuminate\Database\Eloquent\Builder
Note: Eloquent\Builder does NOT use the Macroable trait. It has its own independent
two-tier macro system (local instance macros + global static macros) with inline execution.
// Eloquent\Builder::__call()
public function __call($method, $parameters)
{
// 1. Local macros (instance-level, stored in $this->localMacros)
if ($method === 'macro') { ... }
if ($this->hasMacro($method)) {
// Inline execution — passes $this as FIRST positional argument (not via bindTo)
array_unshift($parameters, $this);
$macro = $this->localMacros[$method];
return $macro(...$parameters);
}
// 2. Global macros (class-level, stored in static::$macros)
if (static::hasGlobalMacro($method)) {
// Also inline execution — binds Closure to $this and calls directly
$callable = static::$macros[$method];
if ($callable instanceof Closure) {
$callable = $callable->bindTo($this, static::class);
}
return $callable(...$parameters);
}
// 3. Model scopes (checked AFTER macros)
if ($this->hasNamedScope($method)) {
return $this->callNamedScope($method, $parameters);
}
// 4. Passthru methods (count, exists, aggregate, etc. → delegated to base query)
if (in_array(strtolower($method), $this->passthru)) {
return $this->toBase()->{$method}(...$parameters);
}
// 5. Forward to underlying Query\Builder
// Uses forwardCallTo (NOT forwardDecoratedCallTo), then returns $this unconditionally
$this->forwardCallTo($this->query, $method, $parameters);
return $this;
}
// Eloquent\Builder::__callStatic() — handles macro/mixin registration
public static function __callStatic($method, $parameters)
{
// 1. 'macro' → register a global macro
// 2. 'mixin' → register methods from an object as macros
// 3. Global macro call → execute the macro
// 4. throw BadMethodCallException
}
Call chain:
User::query()->whereJsonContains('meta->tags', 'php')
│
└─ whereJsonContains() on Eloquent\Builder:
1. Not declared on Eloquent\Builder ✗
2. Not a local/global macro ✗
3. Not a scope on User model ✗
4. Not a passthru method ✗
5. __call → forwardCallTo to Query\Builder (return value ignored)
→ unconditionally returns $this (the Eloquent\Builder)
Result: Eloquent\Builder (not Query\Builder)
__callStatic)Class: Illuminate\Support\Facades\Facade
// Facade::__callStatic()
public static function __callStatic($method, $args)
{
$instance = static::getFacadeRoot(); // checks static::$resolvedInstance cache first,
// then resolves from container. Cached per static::$cached.
if (! $instance) {
throw new RuntimeException('A facade root has not been set.');
}
return $instance->$method(...$args);
}
Call chain:
DB::table('users')
│
└─ table() on Illuminate\Support\Facades\DB:
1. Not a static method on DB ✗
2. Not a static method on Facade ✗
3. __callStatic fires →
getFacadeRoot() → resolves DatabaseManager from container
→ DatabaseManager::table('users')
Result: Query\Builder
Psalm challenge: __callStatic loses taint context. The generated alias stubs (class DB extends DatabaseManager) help with type resolution but don't carry taint annotations. Calling the underlying class directly (DB::connection()->...) works correctly for taint analysis.
__get / __set)Class: Illuminate\Database\Eloquent\Model
// Model::__get()
public function __get($key)
{
return $this->getAttribute($key);
}
// Model::getAttribute() resolution order:
// 1. hasAttribute($key) — single check that covers:
// - $attributes array (database columns)
// - $casts
// - Legacy accessor: getFirstNameAttribute()
// - New Attribute accessor: protected function firstName(): Attribute
// - Class castables
// All handled inside getAttributeValue() if hasAttribute() is true.
//
// 2. method_exists(self::class, $key) — if a method with this name exists
// on the model but isn't an attribute, return null (or throw in strict mode)
//
// 3. isRelation($key) or relationLoaded($key) — load/return the relation
//
// 4. Otherwise: return null
// (or throw if Model::preventAccessingMissingAttributes() is enabled)
Psalm challenge: Database columns aren't declared as PHP properties. The plugin generates virtual properties during scanning from [@property](https://github.com/property) annotations and migration schema analysis.
Plugin handlers: ModelPropertyHandler (schema columns + casts), ModelPropertyAccessorHandler (legacy getXxxAttribute + new Attribute<TGet, TSet>), ModelRelationshipPropertyHandler (relation methods as properties). All registered as closures per concrete model by ModelRegistrationHandler.
__callStatic + __call)Class: Illuminate\Database\Eloquent\Model
// Model::__callStatic()
public static function __callStatic($method, $parameters)
{
// Laravel 12+: check for #[Scope] attribute first
if (static::isScopeMethodWithAttribute($method)) {
return static::query()->$method(...$parameters);
}
// Default: create instance and call as instance method → hits __call
return (new static)->$method(...$parameters);
}
// Model::__call()
public function __call($method, $parameters)
{
// 1. increment/decrement family (increment, decrement, incrementQuietly, etc.)
// 2. Relation resolvers (registered via Model::resolveRelationUsing())
// 3. through{Relation} helper — e.g., $user->throughCars() resolves to
// $user->through('cars') returning a PendingHasThroughRelationship
// 4. Forward to newQuery() → Eloquent\Builder
// Uses forwardCallTo (NOT forwardDecoratedCallTo) — returns Builder directly
return $this->forwardCallTo($this->newQuery(), $method, $parameters);
}
Note: Model::__call does NOT check scopes. Scopes are resolved by Eloquent\Builder::__call after the call is forwarded there.
Call chain:
User::where('active', true)
│
└─ where() on User (static call):
1. Not a static method on User/Model ✗
2. __callStatic fires →
(new User)->where('active', true)
│
└─ where() on User (instance call):
1. Not an instance method ✗
2. __call fires →
forwardCallTo(newQuery(), 'where', [...])
→ returns Eloquent\Builder (forwardCallTo passes through)
Result: Eloquent\Builder<User>
Macroable trait)Trait: Illuminate\Support\Traits\Macroable
trait Macroable
{
protected static $macros = [];
// Register a macro
public static function macro($name, $macro) // $macro is Closure or object
{
static::$macros[$name] = $macro;
}
// Instance calls — the trait's own __call throws if no macro found.
// Classes like Relation and Builder alias this as `macroCall` and write
// their own __call that checks macros inline before forwarding.
public function __call($method, $parameters)
{
if (! static::hasMacro($method)) {
throw new BadMethodCallException(...);
}
$macro = static::$macros[$method];
if ($macro instanceof Closure) {
// Try binding to $this; fall back to static binding if that fails
// (e.g., for first-class callables or Closures from static contexts)
try {
$macro = $macro->bindTo($this, static::class) ?? throw new RuntimeException;
} catch (Throwable) {
$macro = $macro->bindTo(null, static::class);
}
}
return $macro(...$parameters);
}
// Static calls
public static function __callStatic($method, $parameters)
{
if (! static::hasMacro($method)) {
throw new BadMethodCallException(...);
}
$macro = static::$macros[$method];
if ($macro instanceof Closure) {
$macro = $macro->bindTo(null, static::class);
}
return $macro(...$parameters);
}
}
Classes that use Macroable:
Eloquent\Builder — does NOT use the trait; has its own two-tier macro system (see section 2)Query\BuilderRelation (all relation types)Collection / EloquentCollectionRequest, Response, RouterStr, ArrNote: Carbon / CarbonImmutable have their own independent Carbon\Traits\Macro trait,
not Laravel's Macroable. Similar concept, different implementation.
Resolution priority within __call:
Macros are checked at different points depending on the class:
Eloquent\Builder::__call():
1. local macros
2. global macros
3. scopes (named scopes on model) ← checked AFTER macros
4. passthru methods (count, exists, etc. → toBase())
5. forwardCallTo → Query\Builder; return $this
Relation::__call():
1. macros ← checked first
2. forwardDecoratedCallTo → Eloquent\Builder
Collection::__call():
1. macros ← only option (no forwarding)
Example:
// Registration (typically in a ServiceProvider::boot())
Builder::macro('active', function () {
/** [@var](https://github.com/var) Builder $this — bound by Closure::bindTo */
return $this->where('active', true);
});
// Usage
User::query()->active(); // works via __call → macro lookup
Psalm challenge: Macros are registered at runtime, so Psalm cannot see them during static analysis. Users must either:
[@mixin](https://github.com/mixin) annotations pointing to a class with the macro methods declared[@method](https://github.com/method) annotations on the classPlugin handler: None currently. Larastan solves this via runtime introspection: it reads the
actual static::$macros property via PHP reflection at analysis time (the app is already booted, so
macros registered in ServiceProviders are populated). It then uses ClosureTypeFactory to extract
parameter/return types from the Closure objects. Handles Macroable classes, Eloquent Builder (its
own macro system), Facades (via getFacadeRoot()), and Carbon (Carbon\Traits\Macro).
This plugin already boots the app for container bindings, auth config, translations, and views — the same pattern could discover macros. Limitation: only finds macros registered at boot time; conditional macros or those in unbooted providers are invisible.
make() / app() resolutionClass: Illuminate\Container\Container
// Not __call, but worth documenting because the plugin provides type narrowing
app('auth') // → resolves to AuthManager via container bindings
resolve(Foo::class) // → resolves to Foo
app()->make(Bar::class) // → resolves to Bar
Plugin handler: ContainerHandler (implements FunctionReturnTypeProviderInterface + MethodReturnTypeProviderInterface). Bindings are discovered by booting the real Laravel app at plugin init and iterating the container's registered bindings. Also uses AfterClassLikeVisit to queue bound classes for Psalm scanning so resolved types are known before analysis.
Facade::__callStatic
└─ resolves service from Container
└─ Service->method()
Model::__callStatic
└─ #[Scope] attribute? → static::query()->method()
└─ (new static)->method()
└─ Model::__call
└─ increment/decrement?
└─ relation resolver?
└─ through{Relation}? → PendingHasThroughRelationship
└─ forwardCallTo → Eloquent\Builder
└─ Eloquent\Builder::__call
└─ local/global macro?
└─ scope? → Model::scope{Method}() or #[Scope] method
└─ passthru? → toBase()->method()
└─ forwardCallTo → Query\Builder; return $this
└─ Query\Builder::__call
└─ macro? (Macroable trait)
└─ dynamicWhere? (e.g., whereNameAndEmail())
└─ throw BadMethodCallException
Relation::__call
└─ macro? → execute Closure bound to $this
└─ forwardDecoratedCallTo → Eloquent\Builder
└─ (same Eloquent\Builder::__call chain as above)
Macroable::__call (Collection, Str, Request, etc.)
└─ macro? → execute Closure bound to $this
└─ throw BadMethodCallException
forwardDecoratedCallTo vs forwardCallToBoth are in the ForwardsCalls trait. The critical difference:
| Method | Returns $this when proxy returns itself? |
Used by |
|---|---|---|
forwardCallTo |
No, returns raw result | Model::__call (returns result as-is), Eloquent\Builder (discards result, returns $this manually) |
forwardDecoratedCallTo |
Yes — swaps proxy's $this for caller's $this |
Relation |
Note: Eloquent\Builder uses forwardCallTo but discards the return value and unconditionally returns $this. Relation uses forwardDecoratedCallTo which does the $result === $object ? $this : $result swap. Both achieve the same effect (fluent chain returns the caller) but through different mechanisms.
Understanding how Psalm resolves types is critical for plugin development. There are two distinct questions: where is the method found? and what type is returned?
Psalm scans all files and builds ClassLikeStorage for each class. Multiple sources contribute type information, and later sources override earlier ones:
Priority (lowest → highest, last wins):
1. Native PHP types — from actual method signatures: function foo(): string
2. PHPDoc types — [@param](https://github.com/param), [@return](https://github.com/return), [@var](https://github.com/var) override native types
For [@return](https://github.com/return): [@psalm-return](https://github.com/psalm-return) > [@phpstan-return](https://github.com/phpstan-return) > [@return](https://github.com/return) (explicit priority chain)
For [@param](https://github.com/param): all three sources merged by byte offset, not prioritized
3. Stub files — MERGE into existing storage, overwriting matching methods
During scanning, [@mixin](https://github.com/mixin) annotations are just recorded as metadata — they are not resolved yet. Stubs merge into the existing ClassLikeStorage — methods declared in a stub overwrite matching methods from the vendor scan, but methods NOT in the stub survive from the original scan.
Example:
// In Laravel source (scanned):
public function where($column, $operator = null, $value = null, $boolean = 'and')
// Native return type: none. PHPDoc: none useful.
// In our stub (loaded after scanning):
/** [@return](https://github.com/return) self<TModel> */
public function where($column, $operator = null, $value = null, $boolean = 'and') {}
// Stub type wins → return type is self<TModel>
When Psalm encounters $obj->method() during analysis, it follows PHP's own method resolution logic (own → parent → trait → __call), with [@mixin](https://github.com/mixin) inserted as a Psalm-specific step before __call. First match wins:
1. declaring_method_ids lookup (single flat map)
- After scanning/population, own methods, inherited methods, and trait methods
are all merged into one map (declaring_method_ids on ClassLikeStorage)
- This mirrors PHP's flattening: traits are inlined, parent methods are inherited
- Stubs merge into this same map
2. MethodExistenceProvider (plugin handler)
- Fires between declaring_method_ids and [@mixin](https://github.com/mixin) resolution
- Allows plugins to declare methods "exist" without them being in storage
3. [@mixin](https://github.com/mixin) classes
- If method not found in steps 1-2, Psalm checks [@mixin](https://github.com/mixin) targets
- The method is resolved AS IF it were on the mixin class
- MethodReturnTypeProvider fires for the MIXIN class, not the original
- Psalm has two mutually exclusive mixin paths (connected by elseif):
a. Templated mixins ([@mixin](https://github.com/mixin) T) — checked first. Has two inner guards:
- isSingle(): rejects union types (e.g., A|B)
- instanceof TNamedObject: rejects non-object ty...
How can I help you explore Laravel packages today?