Summary
ModelPropertyHandler::doesPropertyExist() (and ModelPropertyAccessorHandler, ModelRelationshipPropertyHandler) only handle read mode — they return null for write operations. This means migration-inferred properties are never recognized for assignments like $model->property = value.
Combined with sealAllProperties="true", this causes UndefinedMagicPropertyAssignment / UndefinedThisPropertyAssignment errors on every model property write for models that rely on migration-based schema inference (i.e., models without explicit @property PHPDoc).
Steps to reproduce
- Configure
sealAllProperties="true" in psalm.xml
- Create a model without
@property annotations (relying on migration-based inference)
- Write to a model property:
$model->published_at = now();
// or inside the model:
$this->published_at = now();
- Run Psalm
Expected behavior
The property should be recognized as valid since it exists in the migration schema.
Actual behavior
ERROR: UndefinedMagicPropertyAssignment
Magic instance property App\MyModel::$published_at is not defined
Root cause
In ModelPropertyHandler::doesPropertyExist():
public static function doesPropertyExist(PropertyExistenceProviderEvent $event): ?bool
{
if (!$event->isReadMode()) {
return null; // ← skips all writes, so the property is "unknown" to Psalm
}
// ...
}
The same pattern exists in ModelPropertyAccessorHandler::doesPropertyExist() and ModelRelationshipPropertyHandler.
Returning null means "I don't know about this property", so Psalm falls back to its default sealed-class behavior and reports the property as undefined.
Suggested fix
Remove or relax the isReadMode() guard in doesPropertyExist() so that migration-inferred columns are recognized for both reads and writes:
public static function doesPropertyExist(PropertyExistenceProviderEvent $event): ?bool
{
// Handle both reads and writes for migration-inferred properties
// ...
$column = self::resolveColumn($fqClasslikeName, $propertyName);
if ($column instanceof SchemaColumn) {
return true;
}
return null;
}
Note: The isReadMode() guard should remain in getPropertyType() and isPropertyVisible() since those only apply to reads.
Workaround
Add @property PHPDoc annotations to the model — these are handled natively by Psalm for both reads and writes.
Summary
ModelPropertyHandler::doesPropertyExist()(andModelPropertyAccessorHandler,ModelRelationshipPropertyHandler) only handle read mode — they returnnullfor write operations. This means migration-inferred properties are never recognized for assignments like$model->property = value.Combined with
sealAllProperties="true", this causesUndefinedMagicPropertyAssignment/UndefinedThisPropertyAssignmenterrors on every model property write for models that rely on migration-based schema inference (i.e., models without explicit@propertyPHPDoc).Steps to reproduce
sealAllProperties="true"inpsalm.xml@propertyannotations (relying on migration-based inference)Expected behavior
The property should be recognized as valid since it exists in the migration schema.
Actual behavior
Root cause
In
ModelPropertyHandler::doesPropertyExist():The same pattern exists in
ModelPropertyAccessorHandler::doesPropertyExist()andModelRelationshipPropertyHandler.Returning
nullmeans "I don't know about this property", so Psalm falls back to its default sealed-class behavior and reports the property as undefined.Suggested fix
Remove or relax the
isReadMode()guard indoesPropertyExist()so that migration-inferred columns are recognized for both reads and writes:Note: The
isReadMode()guard should remain ingetPropertyType()andisPropertyVisible()since those only apply to reads.Workaround
Add
@propertyPHPDoc annotations to the model — these are handled natively by Psalm for both reads and writes.