phlex-forms
A model-bound form builder for Phlex — forms as first-class Phlex classes, types inferred from your model, DaisyUI styling by default (and a plain-HTML theme when you want none), and optional server-truth live validation over phlex-reactive.
class UserForm < Forms::Base
live model: User # real validators, live, focus preserved
def fields # self IS the form — no f. prefix
field :email # name → type=email, required inferred
field :role # AR enum → select, humanized
field :country # belongs_to → select over the association
field :notify # boolean column → toggle
field :bio # text column → textarea
row do
field :first_name
field :last_name
end
submit :primary
end
end
render UserForm.new(model: @user)
For one-offs, the inline builder does the same with a yielded form:
Form(model: @user) do |f|
f.field :email, hint: t("users.email_hint")
f.field :bio
f.submit :primary
end
Installation
# Gemfile
gem "phlex-forms"
gem "daisyui" # optional — the daisy theme; omit for plain semantic HTML
gem "phlex-reactive" # optional — the `live` server-truth validation
phlex-forms exposes its components under the Forms:: namespace as a
Phlex::Kit. Include it wherever you render
components (typically your ApplicationComponent / base view):
class ApplicationComponent < Phlex::HTML
include Forms
end
Form classes (Forms::Base)
Subclass Forms::Base, declare the fields in #fields, render. The whole
builder surface (field, row, group, Input, submit, fields_for, …)
is available as bare calls — the form is an object you can reuse, subclass,
and test in isolation.
class ApplicationForm < Forms::Base
:spaced, field_variants: [:primary] # inherited defaults
end
class UserForm < ApplicationForm
url: "/signup" # merged over the parent's
def fields
field :email
submit :primary
end
end
render UserForm.new(model: @user) # instance args win
render UserForm.new(model: @user) { |f| f.Hidden(:token) } # appends after fields
The field API
f.field(name, *modifiers, **options) is the primary verb: one call renders a
control wrapping the label, the input, and an error (or hint).
| Option | Effect |
|---|---|
label: |
Label text. Defaults to the model's humanized attribute name. label: false omits it. |
hint: |
Help text shown when there is no error. |
as: |
Override the control: :select, :textarea, :toggle, :checkbox, :file, :radio, :hidden, :rich_textarea, or any text-like type. |
required: |
Force the required flag. Otherwise inferred from the model's presence validators. |
choices: |
Choices for a select (implies as: :select). |
| positional modifiers | daisyui variants — :primary, :lg, :ghost, … — stacked onto the input. |
Model-driven inference
field :x interrogates the bound model, so as: is an override, not a
requirement. Precedence (first hit wins):
- Explicit
as: - A positional type modifier (
field :price, :number) choices:→ select- Model structure —
has_rich_text→ rich textarea; ActiveStorage attachment → file input (multipleforhas_many_attached); ActiveRecord enum → select over humanized keys; non-polymorphicbelongs_to(as:countryor:country_id) → select over the association (name="user[country_id]", label and required flag from the association, errors surface from:country) - Non-string column type — boolean → toggle, text → textarea,
date/datetime/time → matching inputs, integer/decimal/float → number with
a sensible
step - The attribute-name map (
email→type=email,password→type=password,phone→type=tel, …) type=text
Length and numericality validators also emit maxlength/min/max
(conditional validators are skipped; caller options always win). Everything is
duck-typed behind respond_to? guards — plain objects, Structs, and form
objects fall through to the name map, exactly as before. Opt out entirely
with:
PhlexForms.configure { |c| c.infer_from_model = false }
Association selects load klass.all per render — pass choices: to scope,
order, or cache.
Variants
daisyui variants stack positionally, per field or as defaults:
f.field :email, :primary, :sm # this field
Form(model: @user, field_variants: [:primary]) # every field in this form
field_variants: [:primary] # every field in this class
PhlexForms.configure { |c| c.field_variants = [:sm] } # everywhere
Defaults stack first, call-site modifiers last — the most local wins.
Layout
row { field :first_name; field :last_name } # responsive grid, columns: 2|3|4
group(legend: "Address") do # <fieldset> + legend
field :street
field :city
end
Both work on inline forms (f.row { … }) and inside fields_for builders.
Escape hatches & custom widgets
The lower-level component methods are always available with stable signatures:
f.Input(:name, :primary, :lg) # bare input, variants stacked
f.Select(:role, choices: roles) # native <select>
f.Textarea(:bio, :ghost)
f.Checkbox(:terms) ; f.Toggle(:notify) ; f.FileInput(:avatar)
f.Label(:email) ; f.Hidden(:token) ; f.submit("Save", :primary)
For a bespoke widget (date picker, tag field, remote select), wrap it in
f.Control and bind through the public helpers — this is the supported path,
not a fork reason:
f.Control(:starts_at, label: "Starts") do
render MyDatePicker.new(
name: f.field_name(:starts_at),
id: f.field_id(:starts_at),
value: f.field_value(:starts_at)
)
end
Icons inside a field (daisyui v5 <label class="input"> pattern):
f.field(:search).wrapped_input(:primary) do
LucideIcon("search", class: "opacity-50")
end
Themes — using phlex-forms without daisyui
Every component resolves through a theme (a role → component-class map). With
the daisyui gem loaded, the daisy theme is the default. Without it — or on
demand — the Plain theme renders bare semantic HTML: the same binding
(names, ids, values, required, errors), variants accepted and ignored, no
styling classes, and stable hooks (aria-invalid, role="alert",
data-field-error, data-field-hint, data-form-row) for your own CSS.
render UserForm.new(model: @user, theme: :plain) # per render
theme: :plain # per class
PhlexForms.configure { |c| c.theme = :plain } # global
# override single roles:
PhlexForms.configure do |c|
c.theme = PhlexForms::Theme.resolve(:plain).with(input: MyInput)
end
The same UserForm class renders under either theme — write the form once,
style it per project. (Plain degradations: searchable: selects fall back to
the native select; rich_textarea falls back to a plain textarea.)
Live validation (server-truth, via phlex-reactive)
The live macro turns the whole form into one reactive component. Blurring a
field (or typing, debounced) POSTs all form fields to a single signed
:validate action; the server assigns a whitelisted slice to the model, runs
the real ActiveModel validators, and morphs the errors back in — the
focused input and caret survive.
class UserForm < Forms::Base
live model: User, debounce: 300
def fields
field :email # uniqueness validates against the real DB
field :password
field :password_confirmation # cross-field confirmation just works
submit :primary
end
end
What you get over any client-side mirror:
- One source of truth — uniqueness,
:if/:unless,:oncontexts, cross-field and custom validators all run, because it is your model. - i18n is plain Rails i18n — no duplicated message catalogs.
- No premature errors — a field's error first appears on blur (
touchedtracking rides the signed token; zero client-side bookkeeping), while fixing a field live-updates errors of fields you already touched. - Progressive enhancement — nothing is ever persisted by
:validate; native submit and your controller stay authoritative. A failed-submit 422 re-render shows all errors as usual.
Constraints: live needs a Forms::Base subclass (the endpoint rebuilds the
form from its class — an inline block cannot be serialized; Form(live: true)
raises and says so). Collection controls (collection_check_boxes,
multi-selects) are excluded from live assignment in v1. Use
live_permit/live_deny to adjust the assignable attributes; setters run on
an in-memory model only.
Client-side fallback (validate: true)
Without phlex-reactive, the bundled Stimulus framework mirrors your validators client-side:
Form(model: @partner, validate: true) do |f|
f.field :title # every validator on :title
f.field :slug, validate: false # opt this field out
f.field :note, validate: { length: { maximum: 30 } } # explicit rules
end
Supported: presence, length (with a live counter), format, numericality,
inclusion, exclusion, confirmation, acceptance. Validators with
:if/:unless/:on are skipped; uniqueness can't be checked client-side —
the server stays authoritative. Register the controllers:
// app/javascript/controllers/index.js
import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
lazyLoadControllersFrom("phlex_forms/controllers", application)
Messages ship for en / fr / af; override via window.PhlexForms.messages.
Nested attributes, collections & escape valves
f.fields_for(:line_items) do |item| # single assoc or has_many
item.field :description
end
f.fields_for(:settings, nested_attributes: false) do |s|
s.field :locale # user[settings][locale] — JSONB/hash columns
end
f.collection_check_boxes(:role_ids, Role.all, :id, :name) do |b|
render b.check_box
render b.label
end
f.collection_select(:country_id, Country.all, :id, :name, prompt: "Select…")
Form(model: @item, scope: false) emits bare field names
(name="quantity") — the shape phlex-reactive row editors and
<template>-cloned rows need. External widgets bind through the public
f.field_name / f.field_id / f.field_value helpers.
Icons
Icons default to a bundled inline SVG so the gem is self-contained. To use
glyphs:
PhlexForms.configure do |c|
c.icon_renderer = PhlexForms::Configuration.glyphs_renderer
end
RuboCop cops
Two cops nudge call sites toward the phlex-forms API. Enable them in your
.rubocop.yml:
require:
- phlex_forms/rubocop
inherit_gem:
phlex-forms: config/rubocop.yml
PhlexForms/RawForm— useForm()overform_with/ rawform()(autocorrects).PhlexForms/LegacyFormMethod— useform.field(...)/ the PascalCase methods over Rails-styletext_field/select/ etc.
Dependencies
- Hard:
phlex(~> 2.0),activesupport,zeitwerk,glyphs. - Soft:
daisyui(the daisy theme — without it the Plain theme is the default),phlex-reactive(thelivemacro). - JS peers:
@hotwired/stimulus(client validation fallback),choices.js(only forsearchable: trueselects).
Companion
Form-level error summaries (FormErrors) live in your app's UI kit, not here —
pair them with Form() as you like.
License
MIT © Mikael Henriksson