
Every WordPress site needs a form. Most of them need one form — a name, an email
address, a message, and a button. The plugins on offer for that job have grown
into marketing platforms with entry-level tiers, add-on marketplaces and a
dashboard that wants to tell you about your conversion rate.
HDForms is the other thing. It is a form builder that builds forms, sends the
email, and stops. It is free, all of it, with no locked features and no account
to create. What follows is what it does, and why the way it does it matters.
Building a form
Forms live under HDForms in the admin sidebar, as their own post type. The
edit screen is the builder, full width, two panes:
- The field list on the left, in the order the form will render.
- The inspector on the right, showing whichever field you have selected, or
the form’s own settings.
That’s the whole interface. Pick a field from the palette, edit it in the
inspector, drag it where it goes.
The palette
Fields are grouped the way somebody building a contact form would look for them,
and every one describes itself in a sentence before you add it:
Text and numbers — Text, Textarea, Email, Website, Phone number, Integer,
Float, Currency, Slider.
Choices — Select box, Radio buttons, Button group, Checkboxes, Toggle switch.
Dates and colour — Date, Time, Date and time, Colour.
Layout — Heading, Content, Divider, Columns, Group.
Advanced — Hidden.
There is a search box at the top of the palette, so on a long list you type
“phone” rather than scanning for it.
Each field type is genuinely typed, not a text box with a label. A phone number
field brings up the number pad on a phone. A currency field is counted in cents
and shown with a symbol. A slider shows the value you have dragged to beside it.
An email field tells somebody their address is wrong before they press send.
The inspector
Select a field and its settings appear on the right, in sections: Basics,
Options, Details, Settings, Appearance, Advanced. The first
screenful is short on purpose — ID, label, required — and everything else is one
section down rather than absent.
Every setting carries a line of help written for the person building the form,
not for the person who wrote the library:
Placeholder — Faint example text inside the field, which disappears as soon
as somebody types. On a drop down it is the wording of the empty first choice.Max length — The most characters somebody can type. Leave it empty for no
limit.Prefix — A little box in front of the field, for something like a currency
symbol orhttps://.
Where a setting really is only useful to a developer — pattern, class, HTML
attributes — the help says “For developers:” and then says what it does anyway.
Columns that behave
Pick Columns, choose a layout — 1-1, 1-2, 2-1, 1-1-1, 1-1-1-1 —
and the empty columns arrive with it, one Group ready to fill in each. Widen the
layout later and the extra column is added to go with it. You are never left
holding a three-column layout with two columns in it.
A Group is the piece that makes this work: it stacks fields, so a single column
can hold a name, an email address and a message rather than one field.
Drag, nest, reorder
The field list is drag and drop. Cards move within a list, into a column, into a
group, out again. Drops that would produce something the renderer cannot draw —
a column inside a column cell, a repeater inside a repeater — are refused as you
drag rather than accepted and broken later.
The builder tells you what is wrong
Two fields sharing an ID. A conditional rule pointing at a field that has been
deleted. A select box with no options in it. A column with no layout chosen.
None of those are things a single field can notice about itself, so the builder
checks the whole tree: each problem appears as a badge on the card it belongs
to, a count in the header, and a confirmation before saving. You are told, and
then you are allowed to decide.
Import and export
Export hands you the form’s fields as JSON, to copy or download. Import
takes it back, on this site or another one, and offers two ways in:
- Replace the fields — throw away what is here and use the imported ones.
- Add to this form — put them after what is already there, renaming any
imported field whose ID is already taken, so no two fields answer to the same
name.
Drop a .json file anywhere on the import box and it reads it.
It knows when you have changed something
The Save button marks itself when the form has unsaved changes, and leaving the
page with changes on it asks first. “Changed” is answered by comparing the form
against what was saved, not by a flag — so selecting a field to look at it does
not count as an edit.
Conditional logic, without code
This is where most form builders either charge you or hand you a JavaScript
snippet.
Every field has a Conditional logic setting: a list of rules, each one
naming another field, an operator, and a value. All the rules have to be true at
once for the field to show.
Nine operators:
is equal to | is not equal to |
is greater than | is less than |
is greater than or equal to | is less than or equal to |
contains | is empty |
is not empty |
Show “Which other service?” when Service is equal to Other. Show the shipping block when Delivery is not empty. Show the discount question when Quantity is greater than or equal to 10.
Two details that matter more than the feature list:
- A field nobody can see is never made to be required. Mark a hidden-by-rule
field as required and it will not block a submission it was never shown for. - The rules are honoured on the server too. The browser evaluates them live
as somebody types; the server revalidates the whole submission and is told
which fields were out of sight, so neither end can be talked into the wrong
answer.
If you have used HDForms before 1.7, this replaces the old single trigger-and-
toggle arrangement, with the id[4] option-index syntax that was documented but
never actually worked. Existing forms are brought across.
Forms that are in the page
HDForms renders on the server. The form’s markup is in the HTML before a single
line of JavaScript runs.
That is not a purity argument. It means:
- Search engines and screen readers see a form, not an empty div waiting for
a script. - It draws with the page, so there is no flash of nothing where the form goes.
- It degrades honestly. The markup is real inputs with real labels.
The JavaScript that follows binds to what is already there: it validates, it
shows and hides on your rules, it collects and posts. It is vanilla — no jQuery
on the front end since 1.6.
Accessibility is built into the fields, not bolted on
Radio groups get role="radiogroup" with aria-checked on the options. Toggles
get role="switch". The success message is an aria-live region, so it is
announced rather than silently painted. Custom controls — the colour picker, the
search list, the image tiles, the repeater row handles — are focusable and
keyboard-operable with the labels to match.
Spam protection with nothing to sign up for
No captcha. No reCAPTCHA key. No third-party service, and nothing about your
visitors sent anywhere. Four layers, all on your own server:
A nonce. WordPress’s own, checked on every submission.
A honeypot. An empty box no visitor sees. Note that it is positioned off
screen rather than display: none — a bot that skips hidden fields is not the
one this catches; a bot that fills in everything it can find is, and it fills
this in.
A signed token. Each rendered form carries an HMAC-signed timestamp. It
proves the form was actually rendered by this site, for this form, at a moment
the server can verify without storing anything. It is good for 12 hours. It is
also refused if the form comes back in under three seconds — nobody fills in a
form that fast, and a bot that loads and posts in the same breath does.
Flood protection. One submission per address per 30 seconds, held as a
self-expiring transient. It is recorded once a message has actually been sent,
so a submission that failed validation does not cost a real visitor their next
turn.
Plus a replay guard: once a message goes out, that submission is spent, so a
double-click, a back button or a restored tab cannot send it twice.
The visitor’s address is read from HTTP_CF_CONNECTING_IP, X-Forwarded-For
and X-Real-IP before falling back to REMOTE_ADDR, because behind a CDN the
latter is the proxy and every visitor on the site would share one rate limit.
That trade is documented in the code and reversible with a one-line filter if
your site would rather have it the other way round.
It works behind a full page cache
This is the unglamorous feature that quietly decides whether a contact form works
on a real, fast site.
A nonce and an anti-spam token are minted when a page is rendered. On a site with
full page caching — which is most sites worth having — a page is rendered once
and then served to everybody. So everybody gets the same nonce and the same
token. The first submission spends them; every visitor after that is told the
form has expired, on a page that can only ever come from the cache. Reloading
does not help, because the reload is the same cache entry.
HDForms fixes this properly rather than by telling you to exclude the page from
caching:
- Nothing is written when a form is rendered. The token is signed, not
stored, so a page view costs no database row. - Fresh credentials are fetched immediately before posting. One small request
per submission attempt, none per page view. - The page stays cacheable. Which was the point.
The replay guard is keyed to the submission — the visitor’s address and the
answers they gave — rather than to the token, so two people sharing a cached page
are two submissions, and one person double-clicking is one.
Where the email goes
The Form tab holds everything about what happens after Send:
Send to. One address per line or separated by commas. It arrives already
filled in with your site’s admin address, until you type over it. And a form
will not save with a typo in it — the bad address is named under the box.
The alternative, which is what most builders do, is to quietly drop the
unparseable address when the email goes out, so the form looks perfectly set up
right until a submission goes nowhere.
Reply-to name and Reply-to address. Name the fields that ask for them and
replies go back to the person who wrote in, rather than to your own site
address. Point one at a field that answers with a list — a checkbox group, say —
and the form refuses to save, for the same reason.
Submit button text and Success message, both with sensible defaults.
The email itself is built for you: an HTML message with every answer under its
label, checkboxes joined up, toggles printed as Yes and No, and the honeypot and
token left out. The subject is the form’s title unless you change it.
An optional log of what was sent
Under About / Options there is a Log Sent Forms setting. Turn it on and
every form the mail server accepts is recorded on your own site: when it was
sent, which form, the subject, who it went to, the visitor’s IP, and everything
that was filled in. The most recent are listed on the same screen with a button
to empty the log.
It is off out of the box, and the plugin says why rather than presenting it as a
free feature you would be silly not to enable:
A form that is emailed and forgotten leaves nothing behind, and a logged one is
a copy of what your visitors typed sat in your database until you clear it.
Turn it on to work out where a missing email went, or to keep a record — and know
that you are then looking after that record. The log keeps its most recent 250
entries and deletes the rest as new ones arrive; hdf_log_max changes the number
or returns 0 to keep everything. Entries live in a post type with no UI, no
archive, no REST, and no query var — nothing but the options screen can read them.
Putting a form on a page
Three ways, all of them the same rendering path:
A block. The HDForms block, with a dropdown of your forms. It is a dynamic
block — it stores the form ID and nothing else, so a form you edit next month
shows its changes on every post it is on, without reopening any of them.
A shortcode. [hdf form="1702"], listed in a Shortcode column on the forms
list so you can copy it.
In a template. <?php echo do_shortcode('[hdf form="1702"]'); ?>
Two ways to look
One site-wide setting decides how forms are styled:
Hybrid (the default) loads only the layout, grid, toggles and error states —
the things a theme has no rules for — and lets your theme’s own typography,
borders and focus rings show through. Forms look like they belong to your site.
Full keeps HDForms’ own input styling throughout, for a theme that has no
opinion worth inheriting.
Either way the assets are enqueued early enough that the stylesheet lands in the
head, and only on pages that actually carry a form.
For developers
HDForms is written to be extended by somebody who knows PHP, without a plugin
API to learn first.
Reading a form
hdf_get_form_fields( $form_id ); // the field definition
hdf_get_form_settings( $form_id ); // where submissions go, button text and so onChanging the email on the server
Two filters run after validation and before the message is built:
// change what is in the message
add_filter( 'hdf_after_server', function ( $values, $form_id, $settings ) {
return $values;
}, 10, 3 );
// change everything around it: subject, recipients, reply-to, success message
add_filter( 'hdf_settings_before_send', function ( $settings, $form_id, $values ) {
$settings['title'] = 'Enquiry from ' . $values['hdf_text_ab12cd']['value'];
return $settings;
}, 10, 3 );Only this submission’s copy of the settings is filtered, so the form itself is
unchanged and the next submission starts clean. Anything you leave out keeps the
saved value.
After it has gone
add_action( 'hdf_after_send', function ( $form_id, $values, $settings, $mail ) {
// $mail holds to, subject, body and headers, as sent
}, 10, 4 );It fires only on messages the mail server took, so it runs on sent forms and no
others. Record it, forward it, push it into your CRM. The submission log is
written off this hook, which is the best evidence that it is enough to build on.
JavaScript at three stages
Print a function into the page with hdf_before or hdf_after, then name it in
one of the three Custom actions boxes on the form’s settings: On load,
Before submit, After submit. Each is called as fn( formId, data ),
where data holds every collected value keyed by field ID, each carryingvalue, type, valid and hidden.
A Before submit function runs after the form has validated and before it is
posted, so it only ever sees a submission that was about to go. Return false,
or set HDF.VARS['hdf-' + formId].email = false, and nothing is sent, nothing is
logged, and no server hook fires.
Adding your own field types
Register a type with the hd_add_new_field_types filter and a matchingrender_<type> function, then add it to the builder’s palette withhdf_form_field_types. The same filter also un-hides types the underlying field
library already knows how to render but that the contact-form palette leaves out
by default — repeatable rows being the notable one, for the times a form needs
“add another guest”.
Other filters
hdf_default_send_to, hdf_log_max, hdf_ip_headers, hdf_actions,hdf_before / hdf_after.
Upgrading is handled for you
Version 1.7 changed how forms are stored. Rather than asking anybody to rebuild
anything:
- Existing forms are converted on update, not the first time each one is
opened. - The old data is left exactly where it was. Nothing writes to
form_blocks
orform_dataagain, and nothing is deleted. - An Update saved forms screen lists every form, shows which storage shape it
is in, previews a conversion without writing anything, and converts one form
per request so a site with fifty forms cannot time out. - Forms keep working throughout, because reading one converts it.
The plugin’s own changelog names the bugs it fixed, including its own — the
trigger syntax that was documented but never worked, the flood protection that
cleared the wrong addresses, the screen layout filters keyed to the wrong post
type. That is a reasonable thing to look for in something you are about to hang
your enquiries on.
So why choose it
It is free, and that is the whole business model. No Pro tier, no add-ons, no
feature you will hit a wall on in month three. Conditional logic, multiple
recipients, custom hooks and a submission log are all just in it.
Nothing leaves your site. No captcha service, no external API, no analytics on
your visitors, no account. Submissions go from your server to your inbox. If you
turn the log on, it is your database.
It is fast, and it is honest about caching. Server-rendered markup, no jQuery,
two small assets loaded only on pages with a form, and a submission path designed
around full page caching instead of in spite of it.
It is built for the person filling the form in. Real typed inputs, validation
that says what is wrong before Send, conditional fields that never demand an
answer to a question nobody saw, and accessibility in the markup rather than in
the marketing.
It is built for the person building the form, too. Every setting explains
itself. The builder tells you what is broken before you save. A bad Send-to
address is refused rather than swallowed. Columns arrive with their columns.
And it is built for you, if you write PHP. Three server hooks, three
JavaScript stages, your own field types, and a documented, supported way to read
a form back out.
It is made by one developer, for their own client work, and maintained because
they use it. That is a smaller promise than most plugins make and a more reliable
one than most plugins keep.