Architecture Decisions
Decisions made during development of the plugin. Contributors should follow these to keep the codebase consistent.
Eloquent Model
@property PHPDoc takes priority over plugin inference
Decision: When a user declares @property on their model class, plugin handlers must defer to it by returning null.
Applies to: All three model property handlers — ModelPropertyHandler, ModelRelationshipPropertyHandler, ModelPropertyAccessorHandler. Check pseudo_property_get_types['$' . $propertyName] before doing any inference.
Why: Users who write @property annotations are explicitly declaring the type they want. The plugin should respect that consistently across all handlers rather than overriding it with inferred types.
Property writes use pseudo_property_set_types, not doesPropertyExist()
Decision: Migration-inferred columns are registered as pseudo_property_set_types on the model’s ClassLikeStorage during afterCodebasePopulated. The property handlers (doesPropertyExist, isPropertyVisible, getPropertyType) remain read-only. The write type is mixed (permissive).
Why: Psalm’s internal InstancePropertyAssignmentAnalyzer assumes that any property claimed as existing by a plugin has a PropertyStorage entry. Returning true from doesPropertyExist() for writes causes crashes because plugin-provided properties don’t have backing storage. Using pseudo_property_set_types is Psalm’s intended mechanism — it’s how @property annotations work natively. The write type is mixed rather than the column type because the actual accepted type depends on casts (e.g., a datetime-cast column accepts Carbon, not just string), and casts from the casts() method are not fully resolvable during afterCodebasePopulated.
See: #446
Write-type registration for accessors and relationships is unconditional
Decision: registerWriteTypesForMethods (which registers pseudo_property_set_types for relationship properties, legacy mutators, and new-style Attribute accessors) runs for all models regardless of the modelProperties config. Only registerWriteTypesForColumns (migration-inferred columns) is gated behind useMigrations.
Why: Accessor and relationship properties are discovered from the model’s own method signatures — they don’t depend on migration files. A user with columnFallback="none" still expects $user->roles = $collection to work when sealAllProperties is enabled. This is consistent with the read-side handlers, which are also unconditional (see below).
See: #446
Model property handlers always run, no per-handler config toggles
Decision: ModelRelationshipPropertyHandler and ModelPropertyAccessorHandler are always registered. Only ModelPropertyHandler (migration-based column inference) is gated by the modelProperties config.
Why: The relationship and accessor handlers use Psalm’s own type inference with no external data source. They produce no false positives, and there’s no real-world scenario where a user would want one but not the other. Exposing per-handler toggles adds config complexity without value. The @property precedence rule (above) is the escape hatch for users who want to override specific properties.
Config
Naming: describe what is configured, not how it works internally
Decision: Config elements should be named from the user’s perspective.
Example: <modelProperties columnFallback="migrations" /> instead of <modelDiscovery source="static" />.
Why:
modelPropertiessays what is being configured (properties on models), not an internal concept (discovery)migrationsis concrete — a Laravel dev immediately knows what it meansstaticwas ambiguous in a static analysis tool context (static analysis? unchanging? parsed from code?)- Config names should not collide with related concepts — “Model directories” config (which is about discovery) sits right below
Class Loading and Discovery
Event-driven model discovery via AfterCodebasePopulated
Decision: Models are discovered from Psalm’s own codebase after it finishes scanning project files, using the AfterCodebasePopulatedInterface event.
How it works:
- Psalm scans all
<projectFiles>and populatesClassLikeStoragefor every class (including full parent hierarchy) ModelRegistrationHandler::afterCodebasePopulated()iterates all known classes- For each concrete
Modelsubclass (checked via$storage->parent_classes), property handler closures are registered directly viaregisterClosure() class_exists($name, true)is called to force-load the class for runtime reflection (needed bygetTable(),getCasts())
Why not directory scanning + config (model_locations)?
- Directory scanning required users to configure a list of directories
- Modular Laravel apps (e.g.
app/Modules/Foo/Models/) were especially prone to this - The plugin duplicated work Psalm already does (finding PHP classes in project files)
Why AfterCodebasePopulated instead of AfterClassLikeVisit?
AfterClassLikeVisitfires during scanning — at that point,parent_classesonly contains the direct parent, not the full ancestor chain- A model extending
BaseModel extends Modelwould be missed becauseModelisn’t inparent_classesyet AfterCodebasePopulatedfires after the populator resolves the full inheritance hierarchy
Why not get_declared_classes() without scanning?
get_declared_classes()only returns classes already loaded into the PHP process- Model classes are typically NOT loaded during Laravel bootstrap — they’re autoloaded on demand
- Would require directory scanning anyway to force-load classes, defeating the purpose
Trade-off: Vendor Model subclasses (e.g. Laravel\Sanctum\PersonalAccessToken) will also be discovered if they appear in Psalm’s scanned files. This is acceptable — the handlers gracefully handle any Model subclass.
Handler registration: Property handlers (ModelRelationshipPropertyHandler, ModelPropertyAccessorHandler, etc.) no longer implement Psalm’s PropertyExistenceProviderInterface etc. Instead, ModelRegistrationHandler registers their static methods as closures via registerClosure(). Registration order is preserved (relationship > factory > accessor > column).