Documentation / Pantry Contact
Pantry Contact
Contents: One contact form. Entries saved. Spam trapped.
One contact form. Entries saved. Spam trapped.
A single contact form with a fixed field set. Tick the fields you want, put the shortcode or the block on a page, and every message is emailed to you and kept in the dashboard. Spam is caught with a honeypot, a timing check and a per address rate limit. There is no CAPTCHA and no second form.
Using it
- Activate the plugin. A Contact menu appears with Entries and Settings.
- Open Contact > Settings and choose the fields, the wording and where the notification goes.
- Put the form on a page, either way round:
- the shortcode
[pantry_contact_form] - the block Contact form (under Widgets in the inserter)
- the shortcode
Both take an optional form attribute, [pantry_contact_form form="12"] and a
Form control in the block sidebar. On its own this plugin has one form and
the attribute does nothing; an add-on that answers
pantry_contact_form_config uses it to hand this plugin another form to
render. See "Another form" below.
The email address field is always shown and always required: it is the
Reply-To address on the notification, so you can answer the message straight
from your inbox.
Without JavaScript
The form is a plain POST to the page it sits on. The handler checks it,
stores it, sends the email and redirects back to the same page with
?pantry-contact=sent, where the success message is shown in place of the
form. Nothing about this needs JavaScript.
When JavaScript is available and Inline submit is on, a small script sends
the same request to admin-ajax.php and swaps the form for the message without
reloading. If the request fails, the browser falls back to the plain post.
Spam handling
Four checks run in this order, before anything is stored:
- Nonce. A WordPress nonce tied to the form.
- Honeypot. A hidden
pantry_contact_websitefield that must stay empty. - Timing. A signed timestamp is put in the form when it is rendered. The
signature is a
wp_hash()of the timestamp, so it cannot be forged, and the message is rejected if it comes back faster than the minimum time or more than a day later. - Rate limit. A transient counts submissions per address per hour. The address is hashed with a salt before it is used as a key.
The visitor's address is never stored in full. The last block is dropped
(203.0.113.45 becomes 203.0.113.0) before it is saved with the entry or put
in the notification email.
Page caching
The form carries a nonce and a signed timestamp, and both stop being accepted about a day after the page was rendered. A page cache that keeps a copy of the page for longer than that hands every visitor an expired form, and they see the "try again" message. Keep the cache lifetime of pages carrying the form under a day, or exclude those pages from the cache.
Settings
All on Contact > Settings, stored in one option, pantry_contact_settings.
Form fields
| Setting | Default | What it does |
|---|---|---|
| Fields shown | Name, Subject, Message | Which of Name, Phone, Subject, Message and Consent appear. Email is always shown. |
| Fields required | Name, Message | Which of those must be filled in. A field that is required but not shown is ignored. |
| Name label | Name | Label for the name field. |
| Email label | Label for the email field. | |
| Phone label | Phone | Label for the phone field. |
| Subject label | Subject | Label for the subject field. Also becomes the entry title. |
| Message label | Message | Label for the message field. |
| Consent wording | I agree to this site storing my message so that it can reply. | The text next to the consent checkbox. |
Form behaviour
| Setting | Default | What it does |
|---|---|---|
| Button label | Send message | Text on the submit button. |
| Success message | Thank you. Your message has been sent. | Shown after a message goes through. |
| Form stylesheet | On | Loads assets/form.css on pages that show the form. Turn it off to style the form entirely in your theme. The block editor loads it either way so the preview matches. |
| Inline submit | On | Loads assets/form.js so the form submits without a page reload. |
Notification email
| Setting | Default | What it does |
|---|---|---|
| Send to | empty | One address per line. Empty means the site admin address. Anything that is not a valid address is dropped on save. |
| Email subject | New enquiry from {site} | Placeholders {site}, {name} and {subject} are replaced. |
Spam and limits
| Setting | Default | What it does |
|---|---|---|
| Minimum time to fill in | 3 | Seconds. A form sent faster than this is rejected. 0 switches the check off. |
| Messages per hour | 5 | How many messages one address may send in an hour. |
| Keep entries | On | Save messages in the dashboard. Off means the notification email only. |
Entries
Every accepted message becomes one pantry_entry post: the subject is the
title, the message is the content, and the rest is post meta. The list table
shows the subject, the sender and the first few words of the message. Only
users with manage_options can see or edit them, and nothing can create one
from the admin screens: entries only come from the form.
Export CSV sits above the list and on the settings screen. It goes through
admin-post.php with a nonce and a capability check, and streams every entry
as CSV with the date, subject, name, email, phone, consent, message, page and
masked address.
Filters and actions
Everything is prefixed pantry_contact_.
pantry_contact_email_to filters the notification recipients.
add_filter( 'pantry_contact_email_to', function ( array $to ): array {
$to[] = 'sales@example.com';
return $to;
} );
pantry_contact_email_subject filters the subject after the placeholders are
replaced. Second argument is the sanitised field values.
add_filter( 'pantry_contact_email_subject', function ( string $subject, array $data ): string {
return '[Website] ' . $subject;
}, 10, 2 );
pantry_contact_email_body filters the plain text body, and
pantry_contact_email_headers filters the headers array. Both take the field
values as a second argument.
add_filter( 'pantry_contact_email_headers', function ( array $headers ): array {
$headers[] = 'Bcc: archive@example.com';
return $headers;
}, 10, 1 );
pantry_contact_errors filters the validation errors, an array of field key to
error code, before the message is accepted. Add to it to reject a message.
add_filter( 'pantry_contact_errors', function ( array $errors, array $data ): array {
if ( false !== strpos( $data['message'] ?? '', 'http://' ) ) {
$errors['message'] = 'required';
}
return $errors;
}, 10, 2 );
pantry_contact_success_message filters the wording shown after a send.
pantry_contact_submitted fires after a message is stored and emailed, with
the field values and the entry ID (0 when entries are not kept).
add_action( 'pantry_contact_submitted', function ( array $data, int $entry ): void {
error_log( 'New enquiry stored as ' . $entry );
}, 10, 2 );
pantry_contact_entry_created fires with the entry ID and the field values
just after the entry is stored.
pantry_contact_rejected fires with the reason a submission was turned away:
nonce, spam, too_fast, expired, rate, required or email.
add_action( 'pantry_contact_rejected', function ( string $code ): void {
if ( 'rate' === $code ) {
error_log( 'Contact form rate limit hit' );
}
} );
pantry_contact_message filters the wording the visitor reads for a rejection
code. A filter on pantry_contact_errors that invents its own code says here
what that code means.
add_filter( 'pantry_contact_message', function ( string $message, string $code ): string {
return 'office_closed' === $code ? 'We are closed until Monday.' : $message;
}, 10, 2 );
pantry_contact_settings_after fires at the end of the settings screen, after
the form, with the settings object. An add-on renders its own panel there.
Anything it needs to save goes through its own handler: that point is outside
the settings form.
Another form
Since 1.1.0 the plugin can render, validate, store and email a form it did not
describe itself. The shortcode and the block take a form attribute, and that
id reaches two filters.
pantry_contact_form_config receives the default configuration and the form
id, empty for the form the settings screen describes. Return the fields,
recipients and success message of another form and everything else carries on
as normal: the same nonce, honeypot, timing and rate limit, the same entry, the
same notification.
add_filter( 'pantry_contact_form_config', function ( array $config, string $form ): array {
if ( 'booking' !== $form ) {
return $config;
}
return array(
'fields' => array(
'name' => array( 'type' => 'text', 'label' => 'Name', 'required' => true ),
'email' => array( 'type' => 'email', 'label' => 'Email', 'required' => true ),
'when' => array( 'type' => 'date', 'label' => 'Preferred date', 'required' => true ),
'room' => array(
'type' => 'select',
'label' => 'Room',
'options' => array( 'small' => 'Small room', 'hall' => 'The hall' ),
),
),
'recipients' => array( 'bookings@example.com' ),
'success_message' => 'Thank you. We will confirm by email.',
);
}, 10, 2 );
A field is an array of:
| Key | What it is |
|---|---|
type |
One of text, email, tel, textarea, checkbox, select, checkboxes, date, number, file. Anything else is treated as text. |
label |
The label, and the heading in the notification email. |
required |
Whether the message is refused without it. |
options |
Value to label, for select and checkboxes. The label is what gets stored. |
accept |
The accept attribute of a file field. |
The field key becomes the entry's meta key, _pantry_contact_<key>. A field
keyed subject becomes the entry title, message becomes the entry body, and
email is the reply-to address on the notification. The keys source, ip
and form are taken by the plugin itself.
A file field renders the input and puts enctype on the form; the file
itself is left to whatever added the field. The value this plugin keeps is the
file name, so the field can be required and read like any other.
pantry_contact_form_choices filters the forms offered in the block's Form
control. Each choice is an array of value and label. The control only
appears when there is more than one.
add_filter( 'pantry_contact_form_choices', function ( array $choices ): array {
$choices[] = array( 'value' => 'booking', 'label' => 'Room booking' );
return $choices;
} );
Every entry that came from another form carries its id in
_pantry_contact_form, and the entry screen reads that entry back with that
form's own fields and labels. The CSV export keeps its fixed columns: the
built-in fields, not the ones another form added.
What it stores
- Options:
pantry_contact_settings,pantry_contact_version - Post type:
pantry_entry - Post meta:
_pantry_contact_name,_pantry_contact_email,_pantry_contact_phone,_pantry_contact_consent,_pantry_contact_source,_pantry_contact_ip, plus_pantry_contact_formand one key per field when the message came from a form supplied throughpantry_contact_form_config - Transients:
pantry_contact_rl_<hash>, one hour each - No custom tables, no cron events, no external requests
Uninstalling removes all of it, entries included.
Why this one is over 2,000 lines of PHP
The Pantry Standard aims for under 1,500 lines of PHP per plugin including the
vendored core, and asks for a written reason above 2,000. This plugin measures
2,749 lines: 742 of those are the vendored includes/core settings screen, and
2,007 are the plugin itself.
The reason is that a contact form is four surfaces, not one: the settings
screen (532 lines, most of it the field declarations the standard asks to be
written as data), the form renderer (271), the submission handler with its four
spam checks, validation and notification email (519), and the entries post type
with its list table and CSV export (398). Cutting any of them would cut
something in the does list. The alternative, splitting entries into a second
plugin, would make a contact form that cannot show you your messages.
About 180 of those lines are 1.1.0's extension points: the form configuration filter and the extra field types it can ask for. They add nothing to the default form, and they are what keeps a second form out of this plugin.
What it does not do
No second form, no field builder, no file uploads, no third-party integrations,
no CAPTCHA. Those are out of scope by design; see the Pantry Standard. The
form attribute and the filters above are extension points, not features: on
its own this plugin still has exactly one form, and the block shows no Form
control because there is nothing to choose between.
Pro modules
What Pantry Pro adds to this plugin. None of it is inside the free plugin, and a module only runs when the free plugin is active.
Contact: Multiple forms
Pantry\Pro\Modules\Contact\Forms in src/modules/contact/class-forms.php.
Adds more contact forms to Pantry Contact 1.1.0 or newer, each with its own
fields, recipients and success message.
The free plugin does all the work. This module only answers its
pantry_contact_form_config filter with the configuration of a pantry_form
post, so every form is rendered, validated, stored and emailed by the same code
as the form on the Contact screen, with the same nonce, honeypot, timing check
and rate limit.
What it adds
- A private Contact forms post type,
pantry_form, under Plugin Pantry, right after Contact and before Contact entries. - A field builder on the form's edit screen: one row per field, no JavaScript framework, and the screen works with JavaScript switched off.
- A Send to and Success message box per form.
- The shortcode for each form, on the form's edit screen, in its list table and in the panel on the Contact settings screen.
- Every published form in the Contact form block's Form control.
- A Form column on the free plugin's entries list.
Using it
- Plugin Pantry > Contact forms > Add form. Give it a name.
- Add fields. Each row is a type, a label, whether it is required, and, for a drop-down or a checkbox group, one choice per line. Three blank rows are always waiting at the bottom; Add another field adds more.
- Fill in Send to (one address per line, empty means the addresses on the Contact screen) and a Success message (empty means the wording on the Contact screen).
- Publish, then put the shortcode on a page, or add the Contact form block and choose the form in its sidebar.
Conventions worth knowing, all of them the free plugin's:
- Give every form a field of type Email. That address is the reply-to on the notification.
- A field labelled Subject becomes the entry's title, and one labelled Message becomes the entry's body. Everything else is entry meta.
- To remove a field, clear its label and save.
- A field keeps the key it was given the first time it was saved, so entries already stored under that key still read back correctly. Renaming a label never changes the key.
Field types
text, email, tel, textarea, select, checkbox, checkboxes
(a group), date, number, and file when the Uploads module is present.
The value stored for a drop-down or a checkbox group is the label the visitor chose, not an internal value, so entries and the notification email read plainly.
Settings
This module has no options of its own: a form is a post, and everything about it is post meta.
| Where | Setting | Stored as |
|---|---|---|
| Form edit screen | Fields (type, label, required, choices) | _pantry_pro_contact_fields post meta, an array of rows |
| Form edit screen | Send to | _pantry_pro_contact_recipients post meta, an array of addresses |
| Form edit screen | Success message | _pantry_pro_contact_success post meta |
| Contact settings screen | Nothing to save: the panel lists the forms, with their shortcodes and an Add form button | - |
Hooks
Three filters for a module that adds a field type. The Uploads module in this plugin uses all three.
pantry_pro_contact_field_types filters the types the builder offers, as type
to label.
add_filter( 'pantry_pro_contact_field_types', function ( array $types ): array {
$types['url'] = __( 'Web address', 'pantry-pro' );
return $types;
} );
pantry_pro_contact_field_row fires inside a builder row, under the choices
box, with the stored field (empty for a blank row) and the row's input name
prefix.
add_action( 'pantry_pro_contact_field_row', function ( array $field, string $name ): void {
printf( '<input type="text" name="%s[hint]" value="%s">', esc_attr( $name ), esc_attr( $field['hint'] ?? '' ) );
}, 10, 2 );
pantry_pro_contact_field_save filters one submitted row before it is stored,
with the raw submitted values as the second argument.
pantry_pro_contact_field_config filters the descriptor a field turns into
before it reaches the free plugin, with the stored row as the second argument.
Add whatever the free plugin needs to render the type.
What it reads from the free plugin
pantry_contact_form_configandpantry_contact_form_choicesfilters.pantry_contact_settings_afteraction, for the panel on the Contact screen.- The
manage_pantry_entry_posts_columnsandmanage_pantry_entry_posts_custom_columnlist-table hooks, for the Form column, and the_pantry_contact_formmeta the free plugin writes with every entry that came from a Pro form.
Install and uninstall
install() does nothing: there is nothing to create. uninstall() deletes
every pantry_form post and the meta on it.
Known limits
- The free plugin's block is still
"multiple": false, so one page can hold one Contact form block. Use the shortcode for a second form on the same page. - The free plugin's CSV export keeps its fixed columns. A Pro form's own fields are on the entry screen, not in that file.
Contact: File uploads
Pantry\Pro\Modules\Contact\Uploads in src/modules/contact/class-uploads.php.
Adds a File upload field to Pro contact forms, for Pantry Contact 1.1.0
or newer with the Multiple forms module.
The file never lands in the public uploads tree. It goes to
wp-content/uploads/pantry-contact-private/, a directory this module creates
with an index.php and an .htaccess that denies everything, under a random
name that has nothing to do with the name the visitor gave. The only way back
to it is a link that asks for manage_options.
What it adds
- The File upload type in the form builder, with two limits per field: largest file in megabytes, and the file types it takes.
- Default limits for fields that set none, on the Contact settings screen.
- An Attached files box on the entry, with a Download button per file.
- The same files, by name and size, at the end of the notification email, each with a signed download link.
- Deletion: the files go when the entry is deleted for good, and when Pantry Pro is uninstalled.
Using it
- Add a field of type File upload to a form and, if the site defaults do not suit, fill in that row's largest file and file types.
- The visitor's browser is told which extensions to offer; the server checks
the size, the extension and the real type of what arrives, with WordPress's
own
wp_handle_upload. - Open the entry to download what came in, or use the link in the email.
Settings
| Where | Setting | Default | Stored as |
|---|---|---|---|
| Contact settings screen, File uploads panel | Largest file, in megabytes | 5 | max_kb in the pantry_pro_contact_uploads option |
| Contact settings screen, File uploads panel | File types, extensions separated by commas | pdf, jpg, jpeg, png, gif, doc, docx, odt, txt, csv, zip |
types in the same option |
| Form builder, per field | Largest file, in megabytes | empty, meaning the setting above | max_kb in the field row |
| Form builder, per field | File types | empty, meaning the setting above | accept in the field row |
The panel's Save button posts to admin-post.php with its own nonce and
capability check rather than joining the free plugin's own Save. WordPress's
options.php writes every option registered in a settings group on every
submit, null included, so a second form in the free plugin's group would wipe
whichever option was not on screen. The panel also shows the path the files go
to and whether that directory is in place.
The server's own upload_max_filesize still wins over anything set here; the
panel prints what it is.
The download link
Both links go through admin-post.php?action=pantry_pro_contact_download and
both need manage_options:
- The button on the entry screen carries a nonce,
pantry_pro_contact_download_<entry>. - The link in the notification email carries a signature instead,
wp_hash( 'pantry_pro_contact_download|<entry>|<index>' ). A nonce belongs to the session of whoever was on the page when it was made, and the person who sent the form is not signed in, so a nonce made there would never verify for the administrator who opens the email. The signature proves the link came from this site; the capability check decides who may use it.
Anything else, a wrong signature, an expired nonce, a signed-out visitor, gets a 403 and no bytes.
Hooks
This module consumes the Multiple forms module's four hooks
(pantry_pro_contact_field_types, pantry_pro_contact_field_row,
pantry_pro_contact_field_save, pantry_pro_contact_field_config) and the
free plugin's pantry_contact_errors, pantry_contact_message,
pantry_contact_entry_created, pantry_contact_submitted,
pantry_contact_rejected, pantry_contact_email_body and
pantry_contact_settings_after. It publishes no hooks of its own.
It adds three rejection codes, with wording through
pantry_contact_message: pantry_pro_file_size, pantry_pro_file_type and
pantry_pro_file_failed.
What it stores
- Option
pantry_pro_contact_uploads. - Post meta
_pantry_pro_contact_fileson an entry: one row per file with the field key, its label, the name the visitor gave, the stored name, the size and the type. - The files themselves in
wp-content/uploads/pantry-contact-private/.
install() creates the directory, its index.php and its .htaccess, and
writes the default limits without overwriting an existing setting. It is safe
to run again, and the directory is remade if it is ever missing when a file
arrives. uninstall() deletes every file, the directory, the meta on every
entry and the option.
Known limits
- The
.htaccessis an Apache file. On nginx the directory is still outside anything WordPress links to and the names are random, but a server rule is the belt to this braces: deny/wp-content/uploads/pantry-contact-private/. - One file per field. A form that needs three attachments gets three fields.
- Uploads need the free plugin's Keep entries setting on. With it off there is no entry to hang a file on, so the file is discarded once the notification has gone and the email says so.
Comparison
How Pantry Contact compares with the popular plugins that do the same job, and when one of them is the better choice.
Pantry Contact compared with Contact Form 7, WPForms Lite, Ninja Forms and Fluent Forms