Skip to content

Everything for WordPress, web development — and beyond

🚀 How to hook into Contact Form 7 before form submission

🚀 How to hook into Contact Form 7 before form submission

Contact Form 7 is installed on millions of WordPress sites. It is simple, reliable, and free. But as soon as standard behavior falls short and you need to validate a field, set a dynamic recipient, or cancel an email based on a condition, you enter the "how is this even done" territory.

The CF7 documentation describes hooks briefly. Forums and Stack Overflow are littered with snippets for old plugin versions, and five-year-old code silently breaks on modern CF7. Let's figure out which hooks are current today and how to hook into them without surprises.

💡 Quick overview:

  • the main hook is wpcf7_before_send_mail, the entry point for any pre-send logic.
  • you can skip the email using the wpcf7_skip_mail filter or a single line skip_mail: on in the form settings.
  • to get submitted data, use the $submission object and the get_posted_data() method.
  • to change the recipient dynamically, use set_properties() on the form object.
  • for field validation, use wpcf7_validate with a filter by field type.

Why you need pre-send hooks

The standard CF7 scenario looks like this: a user fills out fields, clicks "Submit," the plugin sends an email to a fixed address. That's it. Adding logic into this process without hooks is impossible.

The wpcf7_before_send_mail hook and related filters let you intervene in the chain at exactly the moment when data has been validated but the email hasn't been sent yet. You can:

  • check a custom condition and cancel the submission;
  • save form data to a database or external API;
  • change the email recipient on the fly;
  • modify the email content;
  • trigger an integration with a CRM or mailing service.

All examples below work with Contact Form 7 version 5.2 and above. If your plugin is older, update first: old hook signatures are incompatible.

wpcf7_before_send_mail, the entry point

Starting with version 5.2, the hook signature changed. Previously only the form object was passed; now there are three arguments:

1add_filter( 'wpcf7_before_send_mail', 'cf7_before_send_handler', 10, 3 );
2
3function cf7_before_send_handler( $contact_form, $abort, $submission ) {
4 // $contact_form — WPCF7_ContactForm object
5 // $abort — abort flag (true/false)
6 // $submission — WPCF7_Submission object with data
7
8 // your logic here
9
10 return $contact_form;
11}

File for placement: functions.php of your active theme or the Code Snippets plugin. Priority 10 works for most tasks; raise it to 1 if your callback needs to run before others, or lower it to 99 if it should run after.

Note that this hook is a filter, not an action. You must return the $contact_form object (or WPCF7_ContactForm), otherwise the chain will break incorrectly.

Skipping email sending

The most common use case: don't send a notification if a field is filled in a specific way. There are two approaches.

Using the wpcf7_skip_mail filter

Short and clean. The filter accepts a boolean value and the form object:

1add_filter( 'wpcf7_skip_mail', 'maybe_skip_mail', 10, 2 );
2
3function maybe_skip_mail( $skip_mail, $contact_form ) {
4 $submission = WPCF7_Submission::get_instance();
5
6 if ( $submission ) {
7 $data = $submission->get_posted_data();
8
9 // do not send email if field your-reason equals 'test'
10 if ( 'test' === ( $data['your-reason'] ?? '' ) ) {
11 $skip_mail = true;
12 }
13 }
14
15 return $skip_mail;
16}

The WPCF7_Submission::get_instance() method provides access to submission data within this filter. Without it, get_posted_data() is unavailable since $submission isn't passed directly to wpcf7_skip_mail.

Using Additional Settings

If the logic is as simple as it gets (always skip the email for a specific form), go to the admin panel. In the form's "Additional Settings" tab, add one line:

1skip_mail: on

No code required. The plugin stops email sending right after validation, and the user sees a success message. Perfect for demo forms and test environments.

An alternative with the same effect: demo_mode: on. The difference is that demo_mode fully simulates success without touching the mail subsystem, while skip_mail only skips the sending step, preserving all other behavior.

Getting submitted data

The $submission object (the third argument of wpcf7_before_send_mail) gives full access to what the user submitted:

1add_filter( 'wpcf7_before_send_mail', 'cf7_read_form_data', 10, 3 );
2
3function cf7_read_form_data( $contact_form, $abort, $submission ) {
4 // all fields at once
5 $posted = $submission->get_posted_data();
6
7 // specific field (key — name attribute of the tag in the form)
8 $user_name = $submission->get_posted_data( 'your-name' );
9 $user_email = $submission->get_posted_data( 'your-email' );
10
11 // ID of the post from which the form was sent
12 $post_id = $submission->get_meta( 'container_post_id' );
13
14 // form ID
15 $form_id = $contact_form->id();
16
17 // save to log or external service
18 if ( $user_email ) {
19 // for example, add subscriber to mailing list
20 }
21
22 return $contact_form;
23}

Field keys ('your-name', 'your-email') are the name attribute values in form shortcodes. Standard tags like [text* your-name], [email* your-email] produce keys without prefixes; just copy what comes after the space.

Important: get_posted_data() returns raw data before processing by mail templates. Special characters, line breaks, everything as the user entered it. Sanitize before saving to the database using sanitize_text_field() or similar functions.

Dynamic recipient switching

Suppose the email should go to different managers depending on the topic selected in the form. We change the recipient property on the fly:

1add_filter( 'wpcf7_before_send_mail', 'cf7_dynamic_recipient', 10, 3 );
2
3function cf7_dynamic_recipient( $contact_form, $abort, $submission ) {
4 $data = $submission->get_posted_data();
5 $department = $data['your-department'] ?? '';
6
7 $recipients = [
8 'sales' => '[email protected]',
9 'support' => '[email protected]',
10 'billing' => '[email protected]',
11 ];
12
13 if ( isset( $recipients[ $department ] ) ) {
14 $props = $contact_form->get_properties();
15 $props['mail']['recipient'] = $recipients[ $department ];
16 $contact_form->set_properties( $props );
17 }
18
19 return $contact_form;
20}

The get_properties() and set_properties() methods work with the form's settings array, including the mail section. You can change not only the recipient but also the subject (subject), body (body), additional headers (additional_headers), and sender (sender).

Place the code in the same location: your theme's functions.php or via Code Snippets. Test on staging before deploying to production: a typo in the mail array key will be silently ignored, and the email will go to the default address.

Field validation before sending

CF7 provides the wpcf7_validate filter, which fires before wpcf7_before_send_mail. It receives a WPCF7_Validation object and lets you add an error; the form won't submit until the user fixes it:

1add_filter( 'wpcf7_validate_text*', 'cf7_custom_text_validation', 10, 2 );
2add_filter( 'wpcf7_validate_email*', 'cf7_custom_email_validation', 10, 2 );
3
4function cf7_custom_text_validation( $result, $tag ) {
5 $field_name = $tag->name;
6
7 if ( 'your-message' === $field_name ) {
8 $value = $_POST[ $field_name ] ?? '';
9
10 if ( mb_strlen( $value ) < 20 ) {
11 $result->invalidate( $tag, 'Message must be at least 20 characters long.' );
12 }
13 }
14
15 return $result;
16}
17
18function cf7_custom_email_validation( $result, $tag ) {
19 $value = $_POST[ $tag->name ] ?? '';
20
21 if ( $value && ! str_contains( $value, '@' ) ) {
22 $result->invalidate( $tag, 'Enter a valid email.' );
23 }
24
25 return $result;
26}

The filter is named following the pattern wpcf7_validate_<field type>: text* for required text fields, email* for email, textarea* for text areas. Without the asterisk, it applies to optional fields. $tag is an object with form field parameters, including name.

Errors are added using the invalidate($tag, 'error text') method. The text is arbitrary and displays below the form field. For AJAX submission (the default in CF7), the message appears without a page reload.

⁉️🤔 Frequently asked questions

What's the difference between wpcf7_before_send_mail and wpcf7_mail_sent?

wpcf7_before_send_mail fires before the email is sent; you can cancel the email, change the recipient, or modify data in it. wpcf7_mail_sent is an action that fires after successful sending. Use it for logging, triggering webhooks, or database writes when the email is guaranteed to have been sent. If you need to interrupt the process, only before_send_mail will work.

Can I hook in without editing functions.php?

Yes, via the Code Snippets plugin (free, in the WordPress repository). It provides an interface for adding PHP snippets without touching the theme. Snippets can be enabled and disabled individually, which is convenient for debugging. An alternative is WPCode (formerly Insert Headers and Footers), also free and with scope control; you can bind a snippet to a specific form through conditional logic.

How do I check that the hook is actually firing?

The simplest way: error_log( 'HOOK FIRED' ) inside the callback function and checking wp-content/debug.log with WP_DEBUG enabled. For quick debugging without logs, temporarily replace return $contact_form with wp_die('Hook works'). Don't do this on a production site; only on local or staging environments.

What should I do if my code stops working after a CF7 update?

First, check the hook signature. In version 5.2, the number of arguments for wpcf7_before_send_mail increased from 1 to 3. If your callback is declared without the $submission parameter, add it. Second, verify that your function returns the $contact_form object. Third, check the PHP error log; CF7 silently ignores fatal errors inside hooks, the form submits as usual, and you don't see the problem.

Is there a way to subscribe to multiple forms with one hook?

Yes, wpcf7_before_send_mail fires for all forms. Inside the callback, filter by ID: $contact_form->id() returns a number matching the form shortcode ID (for example, [contact-form-7 id="42"]). Compare it with the needed IDs and execute different logic via if or switch.

What to put in theme functions vs. extract into a plugin

CF7 hooks technically work from anywhere: functions.php, Code Snippets, MU-plugin. But there's a practical breakdown that will save headaches when changing themes or updating.

Code tied to business logic (recipient switching, CRM integration, database saves) should go into a separate plugin or Code Snippets. The reason is simple: changing themes shouldn't break client email routing. Field validation, which is often tied to theme layout and classes, can stay in functions.php; it will lose meaning when the theme changes anyway.

Snippets longer than 30 lines should be formatted as an MU-plugin (wp-content/mu-plugins/cf7-custom.php). MU-plugins can't be disabled from the admin panel, execute before regular plugins, and don't require activation; just drop the file in, and the code works.

For completely isolated cases (one form, one site), use functions.php plus a child theme. Fewer files, less confusion.

These same principles apply to any other form plugin, whether it's WPForms, Gravity Forms, or Fluent Forms. Each has its own hooks, but the architectural approach of "business logic separate, presentation logic with the theme" is universal.

The video provides a step-by-step breakdown of custom CF7 field validation with a live example in the WordPress admin panel. The approach is compatible with the wpcf7_validate filter described above, so watch it as a visual supplement to the code from the validation section.