Lessons in this tutorial
A contact form is more than three fields and a send button. It needs a place to send the request, server-side validation, a mail service, and feedback that tells the visitor what happened. This walkthrough connects those pieces in a small WordPress plugin you can install and inspect.
You will build a form with name, email, and message fields; publish it with a shortcode; and send plain-text email to the site administrator. The complete code and an installable ZIP are included. You should be comfortable creating a PHP file and installing a WordPress plugin. Allow roughly 45–60 minutes on a local or staging site.
1. Try the practice form
Start with the interactive example below. Click “Check this form” with empty fields, try an incomplete email address, then fill a valid example. Notice the difference between browser validation and sending a message: this practice form performs the first step only.
PRACTICE FORM · NOTHING IS SENT
Try an empty field or an invalid email, then complete the form. Use made-up details: this example only checks your input in this browser.
Ready to try. No message has been sent.
Browser checks help people correct mistakes, but a visitor can bypass them. The WordPress handler we build next repeats the checks on the server. The finished plugin works without JavaScript; the practice form is a separate demonstration.
2. Prepare WordPress and email
- Use WordPress 6.6 or later with PHP 8.0 or later on a local or staging site. You need permission to install plugins and edit files.
- Check Settings → General → Administration Email Address. The plugin sends messages to that address. Complete any confirmation WordPress requires when changing it.
- On a local site, use its mail-capture tool to inspect messages without contacting anyone. For a live site, configure your hosting mail service or an SMTP/mail-delivery plugin and verify a test email reaches an inbox you control.
- Keep file-manager access available so you can disable the plugin if a PHP editing mistake prevents the dashboard from opening.
This tutorial uses wp_mail(), so it uses the mail configuration already connected to WordPress. It does not include SMTP credentials or a delivery provider. A successful function result means the sending system accepted the request; it does not prove inbox delivery. Check the recipient mailbox and mail-service logs separately.
3. Create the plugin
Inside wp-content/plugins, create a folder named notebook-contact-form. Create notebook-contact-form.php inside it. Use a plain-text editor and check that the file does not end in .php.txt.
wp-content/
└── plugins/
└── notebook-contact-form/
└── notebook-contact-form.phpThe plugin owns the form and request handling. Your theme supplies the surrounding page and typography. That separation lets the form continue working after a theme change.
4. Add the complete code
Paste the entire file below into notebook-contact-form.php and save it as UTF-8. Include the opening <?php once. This code belongs in the plugin file, not in the WordPress page editor or your theme’s functions.php.
<?php
/**
* Plugin Name: Notebook Contact Form
* Description: A small contact form using a shortcode and WordPress mail.
* Version: 1.0.0
* Requires at least: 6.6
* Requires PHP: 8.0
* Author: Kamal Ahmed
* License: GPL-2.0-or-later
*/
defined( 'ABSPATH' ) || exit;
function kncf_rate_key(): string {
$address = (string) ( $_SERVER['REMOTE_ADDR'] ?? 'unknown' );
return 'kncf_' . hash_hmac( 'sha256', $address, wp_salt( 'nonce' ) );
}
function kncf_process( array $input ): string {
foreach ( [ 'kncf_nonce', 'contact_name', 'contact_email', 'contact_message', 'website' ] as $key ) {
if ( ! isset( $input[ $key ] ) || ! is_string( $input[ $key ] ) ) {
return 'invalid';
}
}
if ( ! wp_verify_nonce( $input['kncf_nonce'], 'kncf_contact' ) ) {
return 'expired';
}
if ( '' !== trim( $input['website'] ) ) {
return 'invalid';
}
$name = sanitize_text_field( $input['contact_name'] );
$email = trim( $input['contact_email'] );
$message = sanitize_textarea_field( $input['contact_message'] );
if ( '' === trim( $name ) || '' === trim( $message )
|| strlen( $input['contact_name'] ) > 100
|| strlen( $input['contact_email'] ) > 254
|| strlen( $input['contact_message'] ) > 5000
|| preg_match( '/[\r\n]/', $email ) || ! is_email( $email ) ) {
return 'invalid';
}
if ( get_transient( kncf_rate_key() ) ) {
return 'limited';
}
$recipient = get_option( 'admin_email' );
if ( ! is_email( $recipient ) ) {
return 'failed';
}
set_transient( kncf_rate_key(), 1, MINUTE_IN_SECONDS );
$body = "Name: {$name}\nEmail: {$email}\n\n{$message}";
$sent = wp_mail(
$recipient,
'New website contact message',
$body,
[ 'Content-Type: text/plain; charset=UTF-8', 'Reply-To: ' . sanitize_email( $email ) ]
);
return $sent ? 'sent' : 'failed';
}
function kncf_handle(): void {
if ( 'POST' !== ( $_SERVER['REQUEST_METHOD'] ?? '' ) ) {
wp_die( 'Method not allowed.', '', [ 'response' => 405 ] );
}
$input = wp_unslash( $_POST );
$page_id = isset( $input['page_id'] ) && is_string( $input['page_id'] ) ? absint( $input['page_id'] ) : 0;
$page = get_post( $page_id );
if ( ! $page || 'publish' !== $page->post_status || ! has_shortcode( $page->post_content, 'notebook_contact' ) ) {
wp_die( 'Contact page not found.', '', [ 'response' => 400 ] );
}
$status = kncf_process( $input );
wp_safe_redirect( add_query_arg( 'contact_status', $status, get_permalink( $page_id ) ) . '#notebook-contact', 303 );
exit;
}
add_action( 'admin_post_kncf_send', 'kncf_handle' );
add_action( 'admin_post_nopriv_kncf_send', 'kncf_handle' );
function kncf_no_cache(): void {
if ( is_singular() && has_shortcode( get_post()->post_content, 'notebook_contact' ) ) {
if ( ! defined( 'DONOTCACHEPAGE' ) ) {
define( 'DONOTCACHEPAGE', true );
}
nocache_headers();
}
}
add_action( 'template_redirect', 'kncf_no_cache' );
function kncf_form(): string {
$notices = [
'sent' => 'Your message was accepted for sending. Thank you.',
'invalid' => 'Please check your details and try again.',
'expired' => 'The form expired. Refresh this page and try again.',
'limited' => 'Please wait one minute before sending another message.',
'failed' => 'The message could not be sent. Please try again later.',
];
$status = isset( $_GET['contact_status'] ) && is_string( $_GET['contact_status'] ) ? sanitize_key( wp_unslash( $_GET['contact_status'] ) ) : '';
ob_start();
?>
<div id="notebook-contact">
<?php if ( isset( $notices[ $status ] ) ) : ?>
<p role="status"><?php echo esc_html( $notices[ $status ] ); ?></p>
<?php endif; ?>
<form action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" method="post">
<input type="hidden" name="action" value="kncf_send">
<input type="hidden" name="page_id" value="<?php echo esc_attr( get_queried_object_id() ); ?>">
<?php wp_nonce_field( 'kncf_contact', 'kncf_nonce' ); ?>
<p><label for="kncf-name">Name (required)</label><br><input id="kncf-name" name="contact_name" autocomplete="name" maxlength="100" required></p>
<p><label for="kncf-email">Email (required)</label><br><input id="kncf-email" name="contact_email" type="email" autocomplete="email" maxlength="254" required></p>
<p><label for="kncf-message">Message (required)</label><br><textarea id="kncf-message" name="contact_message" rows="6" maxlength="5000" required></textarea></p>
<div aria-hidden="true" style="position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden"><label for="kncf-website">Leave this empty</label><input id="kncf-website" name="website" tabindex="-1" autocomplete="off"></div>
<p>Your name, email and message will be emailed to the site owner so they can reply.</p>
<button type="submit">Send message</button>
</form>
</div>
<?php
return ob_get_clean();
}
add_shortcode( 'notebook_contact', 'kncf_form' );
Download the complete contact-form plugin ZIP. It contains exactly the PHP file shown above. You can install the ZIP directly or compare it with your own file.
5. Put the form on a page
- Open Plugins → Installed Plugins and activate Notebook Contact Form. For the ZIP, use Plugins → Add New Plugin → Upload Plugin, then install and activate it.
- Create a new Page called “Contact” or a temporary test page. Add a Shortcode block and enter the shortcode shown below.
- Publish the page on your test site, then open its public URL. Add the shortcode once, directly in this page’s content; this minimal example does not support a reusable block, widget, or multiple copies on one page.
- Exclude the page from your hosting/CDN page cache. The plugin also sends no-cache headers and sets the common
DONOTCACHEPAGEflag, but an upstream cache can serve a page before WordPress runs.
[notebook_contact]The form uses a WordPress nonce that can expire. Cached form HTML can therefore become unusable even when the plugin code is correct. After changing cache settings, purge the old page once and check a fresh guest visit.
6. Follow one submission through the code
Render: kncf_form() returns HTML through a shortcode. Labels connect to their fields, required catches empty inputs in the browser, and type="email" helps with email mistakes. The hidden action points to kncf_send, and the hidden page ID gives the server an approved return destination.
Route: submitting the form sends a POST request to WordPress’s admin-post.php. We register both admin_post_kncf_send for signed-in visitors and admin_post_nopriv_kncf_send for guests. Without the second hook, a form that worked during an administrator test could fail for everyone else.
Check the request: kncf_handle() rejects non-POST requests and checks that the destination is a published page containing our shortcode. wp_unslash() removes WordPress’s request slashes once. The processing function rejects missing fields and arrays before treating any input as text.
Validate and clean: kncf_process() verifies the nonce, requires an empty honeypot, removes unsuitable markup from the name and message, validates the email, and rejects newline characters in it. The server also limits input size. The strlen() limits are bytes, so multibyte text can reach the server limit before the browser’s character limit.
Send: the recipient comes from the site settings, never from a visitor-controlled field. The email has a fixed subject and a plain-text body. The validated visitor address goes in Reply-To; the configured mail service controls the sender. Using a visitor’s address as From can conflict with that address’s domain authentication.
Return: the handler redirects with HTTP 303 to the form page and a short status code. The page maps that code to fixed, escaped text. Names, addresses, and messages are not placed in the URL. Refreshing the result page therefore does not resubmit the POST request. A status URL is feedback, not proof that an email was delivered.
7. Understand the protection and its limits
A nonce checks that the request has an expected WordPress token. It is not a password, user permission check, one-time token, or spam filter. WordPress guest nonces are shared by default; this intentionally public form does not use them to identify a visitor. Never reuse this permission model for an administrative action.
The honeypot is a hidden field that ordinary visitors leave empty. The rate limiter temporarily blocks another send from the same server-observed IP for one minute, storing a keyed hash rather than the raw address. This is basic friction for repeated submissions. People on a shared network may share the limit, and reverse proxies can make many visitors appear to have one address. Do not trust a forwarded-IP header without a known proxy configuration.
Transients are a lightweight rate limit, not an atomic queue or a complete defence against determined spam. For a busy public site, evaluate a maintained anti-spam service, stronger limits, and monitoring. The plugin creates no message archive in WordPress, but the mail provider, mailbox, or another mail-logging plugin can retain messages. Explain your actual handling in the site’s privacy information.
8. Test the complete flow
Use a local mail catcher first. When checking live delivery, submit only to an address you control and use an obvious test message. The practice form at the top never reaches this server flow.
- Empty fields: the browser should identify the first missing field.
- Invalid email: the browser should reject it; a request that bypasses the browser must still be rejected on the server.
- Valid guest submission: use a private browser window. The page should return to the form with “accepted for sending,” and the local mail catcher should contain one message.
- Reply address: inspect the email headers and confirm Reply-To contains the visitor’s address while the recipient is the site administrator.
- Repeat submission: send again within a minute. You should get the wait message and no second send. Wait at least a minute before the next normal test.
- Expired or missing nonce: a request without a valid token should fail and never reach mail delivery.
- Transport failure: on staging, use the mail tool’s test/failure controls and confirm the form reports failure rather than success.
- JavaScript disabled: the installed WordPress form should still submit because its main flow is ordinary HTML and PHP.
For reference, the accompanying code was checked in WordPress with mail delivery intercepted: invalid tokens, invalid or oversized input, email-header injection attempts, a valid send, the one-minute limit, and a simulated mail failure. That verifies the handler’s decisions; it does not establish delivery through your hosting provider.
9. Style the form
Add this optional CSS through your site’s custom CSS facility or a child theme stylesheet. It keeps each field readable, fills the available column, and retains visible keyboard focus. The surrounding theme can still supply fonts and colours.
#notebook-contact { max-width: 42rem; }
#notebook-contact label { font-weight: 600; }
#notebook-contact input:not([type="hidden"]),
#notebook-contact textarea {
box-sizing: border-box;
width: 100%;
margin-top: .5rem;
padding: .75rem;
border: 1px solid currentColor;
border-radius: .2rem;
font: inherit;
color: inherit;
background: transparent;
}
#notebook-contact button {
padding: .75rem 1.25rem;
font: inherit;
cursor: pointer;
}
#notebook-contact :focus-visible {
outline: 2px solid currentColor;
outline-offset: 3px;
}10. Troubleshoot and finish
The shortcode prints as text: confirm the plugin is active, the shortcode is spelled correctly, and it is inside a Shortcode block. Use a standard page template that renders its content.
The form is always expired: visit a newly loaded page and check its cache exclusion. Signing in or out changes the relevant nonce context, so reload after switching accounts.
The page says sent but the inbox is empty: check the recipient setting, spam folder, and mail-provider logs. Use the mail service’s own test tool. Confirm its sender-domain setup; changing the form’s success text will not fix mail transport.
You get the wait message: allow a full minute between sends, including a failed transport attempt. On staging, also check whether your reverse proxy makes all requests share an IP.
Text disappears after an error: this small example does not retain submitted fields across redirects. It avoids putting personal details in URLs or adding server-side draft storage. For a richer form, add a deliberate retention strategy and accessible error summaries before collecting sensitive information.
Remove the plugin: deactivate it and remove the Shortcode block from the page, then delete the plugin. Its rate-limit entries expire automatically; email already sent remains subject to the mailbox’s retention settings.
You now have a complete, small contact form and a clear route from input to validation, mail, and feedback. A useful next exercise is a validated subject dropdown: accept only known choices on the server, keep the destination fixed, and test unknown values before publishing it.