Skip to content

Migration-based property handlers don't recognize property writes (sealAllProperties) #446

@alies-dev

Description

@alies-dev

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

  1. Configure sealAllProperties="true" in psalm.xml
  2. Create a model without @property annotations (relying on migration-based inference)
  3. Write to a model property:
$model->published_at = now();
// or inside the model:
$this->published_at = now();
  1. 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions