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