Upgrading from v3 to v4
Requirements
| Dependency | v3 | v4 |
|---|---|---|
| PHP | ^8.2 | ^8.3 |
| Laravel | 11, 12 | 12, 13 |
| Psalm | 6, 7 (beta) | 7 only |
Laravel 11 and Psalm 6 are no longer supported. If you need them, stay on v3.
Breaking changes
Psalm 7 is required
v4 requires vimeo/psalm ^7.0.0-beta17 or later. If your project still uses Psalm 6, upgrade Psalm first:
composer require --dev vimeo/psalm:^7.0.0-beta17
Psalm 7 is still in beta. You may need to add this to your project’s composer.json:
{
"minimum-stability": "dev",
"prefer-stable": true
}
Psalm 7 introduces new issue types that may surface in your codebase. These catch real design problems – fixing them improves your code, but you can suppress them during the upgrade and address them later:
MissingPureAnnotation– a method has no side effects but lacks@psalm-pure. Adding it lets Psalm verify the method stays side-effect-free and enables callers to use it in pure contexts.MissingAbstractPureAnnotation– an abstract method should be declared@psalm-pureso all implementations are guaranteed pure.MissingImmutableAnnotation– a class has no mutable state but lacks@psalm-immutable. Marking it immutable prevents accidental mutation in future changes.MissingInterfaceImmutableAnnotation– an interface should be@psalm-immutableso all implementations are guaranteed immutable.
<issueHandlers>
<MissingPureAnnotation errorLevel="suppress" />
</issueHandlers>
Eloquent relation generics now require a declaring model parameter
All Eloquent relation stubs gained additional template parameters. If your codebase has @psalm-return (or @return) annotations with relation generics, they must be updated:
| Relation type | v3 signature | v4 signature |
|---|---|---|
BelongsTo | BelongsTo<TRelated> | BelongsTo<TRelated, TDeclaringModel> |
HasOne | HasOne<TRelated> | HasOne<TRelated, TDeclaringModel> |
HasMany | HasMany<TRelated> | HasMany<TRelated, TDeclaringModel> |
BelongsToMany | BelongsToMany<TRelated> | BelongsToMany<TRelated, TDeclaringModel> |
MorphOne | MorphOne<TRelated> | MorphOne<TRelated, TDeclaringModel> |
MorphMany | MorphMany<TRelated> | MorphMany<TRelated, TDeclaringModel> |
MorphTo | MorphTo<TRelated> | MorphTo<TRelated, TDeclaringModel> |
MorphToMany | MorphToMany<TRelated> | MorphToMany<TRelated, TDeclaringModel> |
HasOneThrough | HasOneThrough<TRelated> | HasOneThrough<TRelated, TIntermediate, TDeclaring> |
HasManyThrough | HasManyThrough<TRelated> | HasManyThrough<TRelated, TIntermediate, TDeclaring> |
Collection, EloquentCollection, and Builder are unchanged.
Taint analysis runs automatically
In Psalm 6 you had to pass --taint-analysis as a separate flag. Psalm 7 combines type analysis and taint analysis into a single run by default. No flags needed — just run ./vendor/bin/psalm.
New features in v4
New issue types
Plugin issues (suppressible via <PluginIssue>):
InvalidConsoleArgumentName–argument()references an undefined name in the command’s$signatureInvalidConsoleOptionName–option()references an undefined name in the command’s$signatureNoEnvOutsideConfig–env()called outside theconfig/directory (env()returnsnullwhen the config is cached)
<issueHandlers>
<PluginIssue name="InvalidConsoleArgumentName" errorLevel="suppress" />
<PluginIssue name="InvalidConsoleOptionName" errorLevel="suppress" />
<PluginIssue name="NoEnvOutsideConfig" errorLevel="suppress" />
</issueHandlers>
Psalm built-in issues (new detections via taint analysis):
TaintedSql–where(),orWhere(), and other query builder methods now have@psalm-taint-sink sqlannotations, catching SQL injection via dynamic column names
Other improvements
#[Scope]attribute support – Laravel 12+ scope detection alongside the traditionalscopemethod prefix- AST-based cast parsing (reads
casts()method without executing it) - Write-type registration (
pseudo_property_set_types) for model properties - Support for
Attribute<TGet, TSet>accessor templates after()closures,Blueprint::rename(),addColumn(), and more migration methods supported- Auto-discovery of migration directories registered via
loadMigrationsFrom()
Upgrade steps
# 1. Update PHP to 8.3+ and Laravel to 12+ if needed
# 2. Upgrade Psalm to v7
composer require --dev vimeo/psalm:^7.0.0-beta17
# 3. Upgrade the plugin
composer require --dev psalm/plugin-laravel:^4.0
# 4. Update relation generic annotations (add declaring model parameter)
# BelongsTo<Foo> → BelongsTo<Foo, self>
# HasMany<Foo> → HasMany<Foo, self>
# HasOne<Foo> → HasOne<Foo, self>
# BelongsToMany<Foo> → BelongsToMany<Foo, self>
# MorphOne<Foo> → MorphOne<Foo, self>
# MorphMany<Foo> → MorphMany<Foo, self>
# HasManyThrough<Foo> → HasManyThrough<Foo, Intermediate, self>
# HasOneThrough<Foo> → HasOneThrough<Foo, Intermediate, self>
#
# Quick sed for the common cases (run from project root):
find app -name '*.php' -exec grep -l '@psalm-return \(BelongsTo\|HasMany\|HasOne\|BelongsToMany\|MorphOne\|MorphMany\|MorphTo\|MorphToMany\)<' {} \; \
| xargs sed -i 's/@psalm-return \(BelongsTo\|HasMany\|HasOne\|BelongsToMany\|MorphOne\|MorphMany\|MorphTo\|MorphToMany\)<\([^>]*\)>/@psalm-return \1<\2, self>/g'
# HasManyThrough / HasOneThrough need manual edits (add intermediate model).
# 5. Run Psalm and update your baseline
./vendor/bin/psalm --set-baseline=psalm-baseline.xml
# 6. Review new issues
# - InvalidConsoleArgumentName / InvalidConsoleOptionName are real bugs — fix them
# - NoEnvOutsideConfig — move env() calls into config files
# - TaintedSql on Builder::where() — review for actual SQL injection risk