diff --git a/.ai/rules/models.md b/.ai/rules/models.md index b02a5bb912..dc01dcec37 100644 --- a/.ai/rules/models.md +++ b/.ai/rules/models.md @@ -20,3 +20,8 @@ Add behavior as a trait rather than pushing it into a base class. ## Casts go in the $casts property Declare casts with `protected $casts = [...]`, not a `casts()` method, even though Laravel 12 supports the method form. + +## New-style Attribute accessors need @return Attribute generics for Larastan +If a model attribute has a new-style accessor (`protected function locationId(): Attribute`), Larastan drops the property entirely unless the method has a generic PHPDoc: `/** @return Attribute */` (e.g. `Attribute`). Without it, the DB-column extension defers to the accessor extension, the accessor extension requires strict generics, and the property reads as "undefined" — that is why `@property` tags crept into model docblocks (e.g. PR #19480). Fix by adding the `@return Attribute<...>` generic instead of `@property` tags; plain columns without accessors are inferred from migrations automatically and need no annotation. + +TGet is the readable type; **TSet is the writable one — the setter's argument, i.e. what may be assigned**, not what the setter stores. Reserve `never` for attributes genuinely never written: no `set:` closure **and** nothing assigns to them (computed values like `expiresDiffInDays()`). No `set:` closure alone is not enough — an accessor decorating a real column still gets assigned elsewhere, and `never` turns each assignment into `Property Setting::$header_color (never) does not accept mixed`; use `mixed` there. Same reasoning for a setter that narrows: `requestable()` stores `(int) filter_var(...)` but accepts an untyped `$value`, so it is `Attribute`.