Skip to content
Plugin PantryPlugins

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

  1. Activate the plugin. A Contact menu appears with Entries and Settings.
  2. Open Contact > Settings and choose the fields, the wording and where the notification goes.
  3. Put the form on a page, either way round:
    • the shortcode [pantry_contact_form]
    • the block Contact form (under Widgets in the inserter)

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:

  1. Nonce. A WordPress nonce tied to the form.
  2. Honeypot. A hidden pantry_contact_website field that must stay empty.
  3. 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.
  4. 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 Email 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_form and one key per field when the message came from a form supplied through pantry_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

  1. Plugin Pantry > Contact forms > Add form. Give it a name.
  2. 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.
  3. 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).
  4. 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_config and pantry_contact_form_choices filters.
  • pantry_contact_settings_after action, for the panel on the Contact screen.
  • The manage_pantry_entry_posts_columns and manage_pantry_entry_posts_custom_column list-table hooks, for the Form column, and the _pantry_contact_form meta 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

  1. 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.
  2. 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.
  3. 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_files on 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 .htaccess is 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

The plugin