Type inference
field :x interrogates the bound model — as:/choices: are overrides, not requirements.
Precedence#
The control is resolved through a chain — first hit wins:
- Explicit
as: - A positional type modifier —
field :price, :number - Explicit
choices:→:select - Model structure — rich text, attachment, enum,
belongs_to - Non-string column type — boolean, text, dates, numerics
- The attribute-name map —
email→type=email,password→type=password,phone→type=tel, … type=text
The column type beats the name map because it is ground truth for the value's shape; the name map only disambiguates strings. The accepted degenerate case: a text column named email renders a textarea.
Model structure#
| Declaration | Renders |
|---|---|
has_rich_text :body | field :body → rich textarea |
has_one_attached :avatar | field :avatar → file input |
has_many_attached :photos | field :photos → file input, multiple, name="…[photos][]" |
enum :role, {…} | field :role → select over humanized enum keys, round-trips the current value |
belongs_to :country | field :country or :country_id → select over the association |
The association select rewrites the field name to the foreign key (user[country_id]), takes its label and required flag from the association, and builds choices lazily from klass.all with the option text resolved through a name → title → label → to_s chain. Errors Rails attaches to :country still display.
choices: to scope, order, or cache them — the explicit argument always wins.Column types#
| Column type | Control |
|---|---|
boolean | toggle (checkbox + hidden unchecked pair) |
text | textarea |
date / datetime / time | matching input type (datetime → datetime-local) |
integer | number, step: 1 |
decimal (scale n) | number, step: 10⁻ⁿ |
decimal / float (no scale) | number, step: "any" |
string | falls through to the name map |
Validator-derived attributes#
Unconditional validators contribute HTML attributes, merged under caller options:
length: { maximum: 30 }→maxlength="30"on text-like controls,numericality: { greater_than_or_equal_to: 18, less_than_or_equal_to: 130 }→min/maxon number inputs (exclusive bounds map ±1 foronly_integer, otherwise they're skipped),- a presence validator → the
requiredflag and label marker.
Validators with :if, :unless, or :on are skipped — they need server context the renderer doesn't have.
Degradation & the kill switch#
Every model touch sits behind respond_to? guards. Plain objects, Structs, and untyped ActiveModel::Attributes fall through to the name map — exactly the pre-inference behavior. There is no ActiveRecord dependency.
To restore name-map-only inference everywhere:
PhlexForms.configure do |c|
c.infer_from_model = false
end