
If you’ve been building WordPress sites for a while, you probably have at least one site — maybe several — running Contact Form 7. It’s been around since 2007, it’s free, and it does the job. But at some point you cross a line where the job it needs to do gets more complicated, and CF7 starts showing its age. That’s where I found myself recently on a client project, staring at a dozen CF7 forms and a shiny new Gravity Forms licence.
There’s no official migration path between the two plugins. There’s no import/export compatibility. You’re essentially expected to rebuild your forms from scratch. For one or two simple forms that’s annoying but manageable. For a dozen forms with complex fields, specific labels, and carefully configured email notifications? That’s a full afternoon gone, minimum, and a real risk of missing something.
So I built a plugin to do it instead.
This article walks through the whole thing — why you’d want to make this move, how both plugins actually work under the hood, how the migration plugin handles the tricky bits, and the complete code you can drop straight into your own site. Whether you’re a site admin who just wants to get the job done, or a developer who wants to understand what’s happening (or adapt the plugin for your own needs), there should be something useful here for you.
Why Move from CF7 to Gravity Forms?
Before getting into the technical stuff, it’s worth being clear about why this migration makes sense. Both plugins handle forms, so what’s the actual difference?
Contact Form 7 is free, lightweight, and simple. It uses shortcodes dropped into a post or page. Configuration happens in the admin under Contact → Contact Forms, and you get a plain text editor where you write a mix of HTML and shortcode tags like [text your-name] or [email your-email]. It sends email. That’s more or less what it does.
It’s a solid plugin — hence the 5+ million active installations — but it has real limitations once your requirements grow:
- No built-in submission storage. By default, CF7 doesn’t save form entries anywhere. You need a separate plugin like Flamingo for that.
- No conditional logic out of the box. You can’t show or hide fields based on what someone selects elsewhere in the form.
- Limited field types. Basic text, email, textarea, select, checkbox, radio, file. Anything beyond that requires add-ons.
- The shortcode editor has no visual preview. You write shortcodes and hope it looks right on the front end.
- Styling is almost entirely your responsibility.
Gravity Forms is a paid plugin, but it’s a completely different tool. It has a drag-and-drop form builder, stores every submission in the WordPress database, supports conditional logic, has payment integrations (Stripe, PayPal), connects to CRMs and email marketing platforms (Mailchimp, HubSpot, Salesforce), and has a large add-on ecosystem. It also has proper admin notifications and user confirmation emails built in, with merge tags that pull submitted values directly into the email.
For a simple contact form on a small site, CF7 is perfectly fine. But the moment you need submission records, conditional fields, integrations, or anything more complex — Gravity Forms earns its cost pretty quickly.
Real-world reasons I’ve seen for making the switch:
- A client’s legal team wants a full record of every form submission, stored and searchable.
- A booking or enquiry form needs to trigger a CRM entry, not just an email.
- A multi-step form with conditional logic that CF7 simply can’t handle.
- A site redesign where you’re cleaning up the plugin stack and consolidating on tools that do more.
- CF7 spam issues (its CAPTCHA support is limited; GF integrates much better with reCAPTCHA v3 and Akismet).
What the Plugin Does (For Non-Developers)
If you’re a site admin or developer who just wants to use the plugin without reading every line of code, here’s what you need to know.
The plugin adds a page under Tools → CF7 → GF Migrator in your WordPress admin. When you visit it, you’ll see a list of all your existing CF7 forms, each one showing its title and how many fields it has. You tick the ones you want to migrate, click the button, and the plugin creates equivalent forms in Gravity Forms.
After it runs, you get a results table showing:
- Which forms were migrated successfully, with their new Gravity Forms ID
- Which forms (if any) failed, and why
- Any warnings — for example, if a field type has no direct equivalent in GF and was skipped, you’ll see a note about it
What it migrates:
- All the form fields, with their labels, placeholders, required status, and CSS classes
- Select/checkbox/radio options
- File upload settings (allowed types, max size)
- Number field ranges (min/max)
- Admin notification emails (to, from, subject, body — with field values correctly translated)
- User confirmation email (Mail 2 in CF7) if it was active
What it doesn’t migrate:
- Form submissions/entries (the plugin is for form structure only)
- CF7’s confirmation message (you can set this up in GF after migration)
[quiz]and[captcha]field types (no equivalent in standard GF)- Any custom CF7 add-on fields that aren’t part of the core field set
After migration, you should go through each form in Gravity Forms and:
- Check that labels look right (most will be spot-on, but complex HTML labels might need a tidy)
- Verify your notification emails sent correctly and that the merge tags look right
- Update your page/post shortcodes from
[contact-form-7 id="X"]to[gravityforms id="Y"] - Test the form end-to-end
That last point is important. Always test after migration. This plugin automates the tedious work, but it can’t substitute for a real test submission.
How Contact Form 7 Stores Its Data
This section is primarily for developers, but it’s useful context for anyone curious about why migration isn’t trivial.
CF7 stores each form as a WordPress custom post type called wpcf7_contact_form. The post title is the form name you see in the admin. The actual form content — the fields — is stored as the post’s content, as a mix of HTML and shortcodes. Here’s a typical example:
<p><label>Your Name<br>[text* your-name placeholder "Full name"]</label></p>
<p><label>Email Address<br>[email* your-email]</label></p>
<p><label>Phone Number<br>[tel your-phone]</label></p>
<p><label>Message<br>[textarea your-message]</label></p>
<p>[submit "Send Message"]</p>
That’s it. That’s the form definition. There’s no structured schema, no JSON, no database table for fields. It’s a flat string.
The email settings (Mail tab in the admin) are stored as post meta, serialised. You can access them via $cf7_form->prop('mail') which returns an array like this:
[
'active' => true,
'recipient' => 'admin@example.com',
'sender' => '"My Site" <noreply@example.com>',
'subject' => 'New enquiry from [your-name]',
'body' => "Name: [your-name]\nEmail: [your-email]\nMessage: [your-message]",
'additional_headers' => 'Reply-To: [your-email]',
'attachments' => '',
'use_html' => false,
'exclude_blank' => false,
]
The [your-name] tags in the subject, body, and headers are CF7’s own mail tag format. They reference field names, not field IDs.
CF7 also has a second mail (Mail 2), used for user confirmation emails:
$cf7_form->prop('mail_2');
This has the same structure, plus an active flag — because Mail 2 is disabled by default.
Parsing CF7 Shortcodes
CF7 ships with an internal tag manager that you can use to parse the form content into structured tag objects. Rather than writing our own shortcode parser, we can call:
$tags = $cf7_form->scan_form_tags();
This returns an array of WPCF7_FormTag objects. Each tag object has:
$tag->name— the field name (e.g.your-email)$tag->type— the full type including optional*for required (e.g.email*)$tag->basetype— just the type without the asterisk (e.g.email)$tag->values— for select/checkbox/radio, the option values$tag->get_option('placeholder', '', true)— named options
There’s one big thing missing from the tag object: the label. CF7 labels are plain HTML sitting outside the shortcode tag, wrapping it in a <label> element. The tag object has no idea about the label. That’s a deliberate design choice in CF7 — it keeps the shortcode minimal and lets you structure the HTML however you like — but it means we have to extract labels separately.
How Gravity Forms Stores Its Data
Gravity Forms takes a completely different approach. Every form is stored as a serialised PHP array in a custom database table (wp_gf_form). The public PHP API is GFAPI, and to create a form programmatically you call:
$form_id = GFAPI::add_form( $form_array );
The $form_array is a structured associative array. Here’s a minimal example:
$form = [
'title' => 'Contact Form',
'labelPlacement' => 'top_label',
'button' => [ 'type' => 'text', 'text' => 'Submit' ],
'fields' => [
[
'id' => 1,
'type' => 'text',
'label' => 'Your Name',
'isRequired' => true,
'placeholder' => 'Full name',
],
[
'id' => 2,
'type' => 'email',
'label' => 'Email Address',
'isRequired' => true,
],
],
'notifications' => [
'1' => [
'id' => '1',
'name' => 'Admin Notification',
'isActive' => true,
'to' => 'admin@example.com',
'from' => 'noreply@example.com',
'fromName' => 'My Site',
'subject' => 'New enquiry from {Your Name:1}',
'message' => "Name: {Your Name:1}\nEmail: {Email Address:2}",
],
],
'confirmations' => [
'1' => [
'id' => '1',
'name' => 'Default Confirmation',
'isDefault' => true,
'type' => 'message',
'message' => 'Thank you, we\'ll be in touch.',
],
],
];
A few things to note about GF’s format:
- Field IDs are integers, sequentially numbered from 1. They matter for merge tags.
- Merge tags use the format
{Label:id}— the label text, a colon, then the field ID. So for a field with label “Your Name” and ID 1, the merge tag is{Your Name:1}. - Notifications and confirmations are sub-arrays keyed by string ID.
- Checkbox fields have an extra
inputsarray because each checkbox item is a sub-field with its own ID (e.g.1.1,1.2).
GFAPI::add_form() returns the new form’s integer ID on success, or a WP_Error object on failure. That’s important — we check for is_wp_error() before treating the return value as an ID.
The Migration Challenge
With both data models understood, the migration problem comes into focus:
- Parse CF7’s flat-string shortcode format into structured field data
- Map CF7 field types to their GF equivalents
- Recover labels from the surrounding HTML, since the tag objects don’t have them
- Translate CF7 mail tags (
[field-name]) into GF merge tags ({Label:id}) - Build a valid GF form array and write it via
GFAPI::add_form()
Steps 1 and 5 are relatively straightforward. Steps 2, 3, and 4 are where it gets interesting.
Field Type Mapping
Here’s how CF7 field types map to Gravity Forms:
| CF7 type | GF type | Notes |
|---|---|---|
text | text | Direct match |
email | email | Direct match |
tel | phone | Different name, same purpose |
url | website | Different name |
number | number | Min/max options transfer |
date | date | Mapped to GF datepicker |
textarea | textarea | Direct match |
select | select | Options transfer as choices array |
checkbox | checkbox | Requires extra inputs array in GF |
radio | radio | Options transfer as choices array |
file | fileupload | Allowed types and size limit transfer |
hidden | hidden | Default value transfers |
acceptance | checkbox (1 item) | Single mandatory checkbox; labelled with warning |
submit | (form-level button) | GF handles this at form level, not as a field |
quiz | (skipped) | No GF equivalent in core |
captcha / recaptcha | (skipped) | GF has its own reCAPTCHA integration |
The checkbox mapping deserves a bit of explanation. In CF7, [checkbox agree "I accept the terms"] is straightforward — one checkbox, one option. In Gravity Forms, a checkbox field has both a choices array (the visual options) and an inputs array (the sub-field definitions, each with its own ID like 1.1, 1.2, etc.). This is because GF needs to be able to reference individual checkboxes in conditional logic and merge tags. The migration handles this automatically.
Recovering Labels
As mentioned earlier, CF7 labels live in the HTML, not in the shortcode. The form content looks like:
<label>Development Address/Eircode<br>[text address]</label>
To extract the label for the address field, we scan the raw form HTML with a regex that captures text between <label> and <br>, then pairs it with the field name that appears in the shortcode immediately after the break:
preg_match_all(
'/<label[^>]*>(.*?)<br\s*\/?>\s*\[[\w*]+\s+([\w-]+)/is',
$body,
$matches,
PREG_SET_ORDER
);
This handles both <br> and <br />, is case-insensitive, and copes with multi-line labels. The result is a ['field-name' => 'Label Text'] lookup array that we carry through the whole conversion process.
For any field that doesn’t have a <label> wrapping it (uncommon but valid CF7), we fall back to deriving a label from the field name itself: your-first-name becomes First Name, units-residential becomes Units Residential, and so on.
Translating Mail Tags
CF7 mail bodies look like:
Name: [your-name]
Email: [your-email]
Phone: [your-phone]
Message: [your-message]
GF merge tags look like:
Name: {Your Name:1}
Email: {Email Address:2}
Phone: {Phone Number:3}
Message: {Message:4}
The difference isn’t just syntax — GF merge tags encode both the label and the field ID, so GF knows exactly which field’s value to insert. To do this translation, we need to know what ID each field got during migration. That means building a name → { id, label } map at conversion time and using it when we process the mail settings.
CF7 also has system tags — special tags that don’t correspond to form fields:
| CF7 tag | GF equivalent |
|---|---|
[_site_title] | get_bloginfo('name') (inlined as plain text) |
[_site_url] | get_site_url() (inlined as plain text) |
[_date] | {date_mdy} |
[_ip] | {ip} |
[_user_agent] | {user_agent} |
[all_fields_table] | {all_fields} |
Any tag not matched by either rule is left as-is in the translated body, so nothing is silently lost.
Building the Plugin
Now let’s walk through the actual code. The plugin is a single PHP file — no build process, no Composer, no dependencies beyond the two plugins it bridges.
Design Philosophy
I wrote this as functional PHP rather than a class. WordPress plugins are often written as classes for namespacing, but with PHP 8+ you can prefix all your function names (I used cf7gf_) and get the same protection without wrapping everything in a class. The functional approach means every function does one thing, takes what it needs as arguments, and returns a value — easier to read, easier to test, easier to modify.
Plugin Header and Bootstrap
<?php
/**
* Plugin Name: CF7 → Gravity Forms Migrator
* Description: Migrate Contact Form 7 forms to Gravity Forms via a simple admin UI.
* Version: 2.0.0
* Requires PHP: 8.1
*/
if ( ! defined( 'ABSPATH' ) ) exit;
const CF7GF_MENU_SLUG = 'cf7-to-gf-migrator';
const CF7GF_NONCE_ACTION = 'cf7gf_migrate';
const CF7GF_NONCE_FIELD = 'cf7gf_nonce';
const CF7GF_TRANSIENT = 'cf7gf_migration_results';
const CF7GF_SKIPPED_TYPES = [ 'quiz', 'captcha', 'recaptcha', 'submit' ];
add_action( 'admin_menu', 'cf7gf_register_menu' );
add_action( 'admin_post_cf7gf_migrate', 'cf7gf_handle_migration' );
The if ( ! defined( 'ABSPATH' ) ) exit; guard is standard WordPress plugin practice — it prevents the file from being executed directly in a browser. The two add_action calls are the entire bootstrap: one registers the admin menu page, the other handles the form POST.
Dependency Checks
Before showing the migration UI, we check that both plugins are actually active:
function cf7gf_dependency_errors(): array {
return array_values( array_filter( [
! class_exists( 'WPCF7' ) ? 'Contact Form 7 is not active.' : null,
! class_exists( 'GFAPI' ) ? 'Gravity Forms is not active.' : null,
] ) );
}
If either class is missing, we show an error notice and stop. This prevents confusing fatal errors if someone installs the migrator without having both other plugins active.
The Admin UI
The selection screen lists all CF7 forms as a table with checkboxes. There’s a “select all” checkbox at the top:
function cf7gf_fetch_cf7_posts(): array {
return get_posts( [
'post_type' => 'wpcf7_contact_form',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
] );
}
CF7 stores forms as a custom post type (wpcf7_contact_form), so a standard get_posts call fetches them all. We also show a field count per form (excluding the submit button, which isn’t a real field) so you can get a quick sense of each form’s complexity before migrating.
The form POSTs to admin-post.php with an action of cf7gf_migrate, which triggers the admin_post_cf7gf_migrate hook. This is the standard WordPress pattern for admin form submissions.
The nonce field (wp_nonce_field / check_admin_referer) is essential. Without it, the action endpoint would be accessible to anyone who knew the URL, which could be used to create garbage GF forms or, if the migration was destructive, worse things. Always use nonces for admin form submissions.
The Migration Handler
function cf7gf_handle_migration(): void {
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Unauthorized.' );
check_admin_referer( CF7GF_NONCE_ACTION, CF7GF_NONCE_FIELD );
$ids = array_map( 'intval', (array) ( $_POST['cf7_form_ids'] ?? [] ) );
$results = array_map( 'cf7gf_migrate_one_form', $ids );
set_transient( CF7GF_TRANSIENT, $results, 120 );
wp_safe_redirect( add_query_arg( [ 'page' => CF7GF_MENU_SLUG ], admin_url( 'tools.php' ) ) );
exit;
}
The array_map( 'intval', ... ) sanitises the submitted IDs — we never trust raw $_POST values, especially when they’re going to be used to look up database records.
The results are stored in a transient and then we redirect back to the admin page. The redirect-after-POST pattern prevents the browser from re-submitting the form if the user refreshes the page. The transient is deleted as soon as it’s read on the next page load.
Building the GF Form Array
This is the core of the plugin:
function cf7gf_build_gf_form( WPCF7_ContactForm $cf7_form ): array {
$tags = $cf7_form->scan_form_tags();
$warnings = [];
$labels = cf7gf_extract_labels( $cf7_form );
$name_map = cf7gf_build_name_map( $tags, $labels );
$indexed = array_map( null, range( 1, count( $tags ) ), $tags );
$gf_fields = array_values( array_filter( array_map(
fn( $pair ) => cf7gf_convert_tag( $pair[1], $pair[0], $warnings, $labels ),
$indexed
) ) );
$notifications = [];
$mail = $cf7_form->prop( 'mail' );
if ( ! empty( $mail ) ) {
$notifications['1'] = cf7gf_build_notification( $mail, $name_map, '1', 'Admin Notification' );
}
$mail2 = $cf7_form->prop( 'mail_2' );
if ( ! empty( $mail2 ) && ! empty( $mail2['active'] ) ) {
$notifications['2'] = cf7gf_build_notification( $mail2, $name_map, '2', 'User Confirmation' );
}
return [ $form_array, $warnings ];
}
The array_map( null, range(1, count($tags)), $tags ) line is a PHP zip — it pairs each tag with a sequential integer starting at 1. This gives us the field IDs we’ll assign in GF without needing a mutable counter variable.
We build the label map and the name map before converting any fields, because the name map needs to be available later when we process the mail settings. The name map mirrors the field conversion logic exactly — it skips the same field types that the converter skips, so IDs stay in lockstep.
The Field Mapper Registry
Each field type has its own conversion logic, collected into a single lookup table:
function cf7gf_field_mapper( string $basetype ): ?callable {
$registry = [
'text' => fn( $tag ) => [
[ 'type' => 'text', 'placeholder' => cf7gf_placeholder( $tag ) ],
[],
],
'select' => fn( $tag ) => [
[ 'type' => 'select', 'choices' => cf7gf_choices( $tag->values ) ],
[],
],
'checkbox' => fn( $tag, $id ) => [
[
'type' => 'checkbox',
'choices' => cf7gf_choices( $tag->values ),
'inputs' => cf7gf_checkbox_inputs( $id, $tag->values ),
],
[],
],
// ... all other types ...
];
return $registry[ $basetype ] ?? null;
}
Each entry is a closure that receives the CF7 tag object (and optionally the field ID, needed for checkbox sub-inputs) and returns a two-item array: [ $extra_field_properties, $warnings ]. The $extra_field_properties get merged with the base field properties (id, label, isRequired, cssClass) that all fields share.
Returning null from the registry lookup triggers a warning and skips the field. Returning an empty warnings array [] is intentional — it keeps the return shape consistent so callers can always destructure [ $extra, $warnings ] without checking first.
The acceptance type deserves a mention here. CF7’s [acceptance] is a single mandatory checkbox, often used for GDPR consent or terms-and-conditions. GF doesn’t have a dedicated acceptance type, so we map it to a single-item checkbox and add a warning so you can check the label. The option text from the CF7 tag (if any) becomes the checkbox label.
Extracting Labels
function cf7gf_extract_labels( WPCF7_ContactForm $cf7_form ): array {
$body = $cf7_form->prop( 'form' );
preg_match_all(
'/<label[^>]*>(.*?)<br\s*\/?>\s*\[[\w*]+\s+([\w-]+)/is',
$body,
$matches,
PREG_SET_ORDER
);
$build = fn( array $carry, array $m ): array => array_merge(
$carry,
[ trim( $m[2] ) => trim( wp_strip_all_tags( html_entity_decode( $m[1] ) ) ) ]
);
return array_reduce( $matches, $build, [] );
}
The regex pattern breaks down as:
/<label[^>]*>— opening label tag (with any attributes)(.*?)— capture the label text (non-greedy)<br\s*\/?>— the line break between label and field (both<br>and<br />)\s*— optional whitespace\[[\w*]+\s+— the opening of the CF7 shortcode ([text*,[email, etc.)([\w-]+)— capture the field name- The
isflags make it case-insensitive and make.match newlines
After capturing, wp_strip_all_tags removes any inline HTML from the label text (some people put <strong> or <span> in their labels), and html_entity_decode handles HTML entities like &.
The array_reduce with a functional reducer folds all the matches into a clean associative array. If the same field name appeared twice for some reason, the last match wins.
Translating Mail Tags
function cf7gf_convert_mail_tags( string $text, array $name_map ): string {
return preg_replace_callback(
'/\[([\w-]+)\]/',
function ( array $m ) use ( $name_map ): string {
$name = $m[1];
if ( isset( $name_map[ $name ] ) ) {
$f = $name_map[ $name ];
return '{' . $f['label'] . ':' . $f['id'] . '}';
}
return match ( $name ) {
'_site_title' => get_bloginfo( 'name' ),
'_site_url' => get_site_url(),
'_date' => '{date_mdy}',
'_ip' => '{ip}',
'_user_agent' => '{user_agent}',
'all_fields_table' => '{all_fields}',
default => $m[0],
};
},
$text
);
}
preg_replace_callback is the right tool here because we need to look up each match in the name map rather than doing a simple string replacement. For each [something] found in the text, we check the name map first. If found, we produce the GF merge tag format. If not, we check if it’s one of CF7’s system tags and map to the GF equivalent. If it’s neither, we leave the original tag unchanged — so if there’s a custom CF7 tag we don’t recognise, it stays in the text and you can handle it manually.
Parsing the Sender Field
CF7’s sender field can be in any of these formats:
"My Site" <noreply@example.com>
My Site <noreply@example.com>
noreply@example.com
But GF stores the from-name and from-email as separate fields. This function handles the split:
function cf7gf_parse_sender( string $sender ): array {
if ( preg_match( '/^["\']?([^"\'<>]+?)["\']?\s*<([^>]+)>/', trim( $sender ), $m ) ) {
return [ trim( $m[1] ), trim( $m[2] ) ];
}
return [ '', trim( $sender ) ];
}
If the sender matches the Name <email> pattern (with or without quotes around the name), we split it. Otherwise we treat the whole string as an email address and leave the name blank.
Use Cases
Here are some real scenarios where this migration plugin is useful.
Use Case 1: The site that grew
You set up a small business site three years ago with CF7 for a basic contact form. The business has grown, the marketing team wants form submission data in a spreadsheet, the sales team wants leads in their CRM, and someone mentioned GDPR compliance. CF7 can’t do any of that without a stack of separate plugins. You buy a Gravity Forms licence, run the migrator, and your existing form appears in GF ready to connect to whatever integration you need.
Use Case 2: Redesign with plugin consolidation
You’re rebuilding a site and taking the opportunity to clean up a plugin list that grew organically over several years. You have CF7 plus Flamingo (for entry storage) plus a CF7 Stripe add-on, and you decide to replace all three with Gravity Forms which does all of it in one. The migrator handles the form structure; you then set up Stripe in GF from scratch (GF’s payment integration is more capable than the CF7 add-on anyway).
Use Case 3: Developer rebuilding a client’s site
A client hands you a live site with 14 CF7 forms ranging from simple to complex. Some have multiple notification recipients, some have file uploads with specific allowed types, some have custom labels. Rebuilding all 14 manually would take hours and introduce errors. The migrator runs in under a minute, then you do a quick review of each form, fix any minor label issues, and move on.
Use Case 4: Staging before switching
You want to be careful — the site is live with real traffic. You spin up a staging clone, install the migrator there, run it, review the results thoroughly, note anything that needs manual adjustment, then perform the same migration on the live site with confidence that you know what to expect.
Use Case 5: Partial migration
You don’t want to migrate everything at once. You have three forms that get the most traffic and need GF’s conditional logic the most. The migrator’s checkbox UI lets you select exactly those three. The rest stay in CF7 for now.
Use Case 6: Multi-site
If you’re running WordPress Multisite and need to migrate forms on multiple subsites, you’d run the migrator on each subsite in turn. The plugin works per-site as any normal WordPress plugin does.
What Happens to the CF7 Forms After Migration?
Nothing — the migrator is non-destructive. Your CF7 forms are untouched. This is deliberate. If something doesn’t look right in the migrated GF forms, you still have the originals to refer back to. Once you’ve verified everything looks correct and tested your forms, you can deactivate CF7 (after updating your page shortcodes), and delete the migrator plugin too since it’ll no longer be needed.
Speaking of shortcodes: CF7 forms are embedded with [contact-form-7 id="X" title="Form Name"]. Gravity Forms uses [gravityforms id="Y"]. After migration you’ll need to go through your pages and posts and update these. If you have a lot of them, the WordPress post editor’s find-and-replace or a SQL update via WP-CLI can help:
wp search-replace 'contact-form-7 id="42"' 'gravityforms id="7"'
Where 42 is the old CF7 post ID and 7 is the new GF form ID (shown in the migrator results table).
After Migration: A Checklist
Once the migrator has run, go through this before switching traffic to the new forms:
- Open each migrated form in the GF form editor and scan through the fields. Labels, types, and options should all look right. Pay particular attention to any field that got a warning.
- Check the notifications. Open each form’s Notifications screen in GF and verify the to, from, subject and body. Look at the merge tags — they should reference your actual field labels and IDs (e.g.
{Email Address:2}). - If you had Mail 2 (user confirmation) enabled in CF7, check that it migrated correctly. It will appear as a second notification called “User Confirmation”.
- Submit a test entry on each form. Check that the notification email arrives with the right content.
- Update page/post shortcodes from CF7 to GF format.
- If any field types were skipped (quiz, captcha, etc.), add them manually in GF.
- Set up GF’s spam protection. GF has its own honeypot field and integrates with Akismet and reCAPTCHA v3 — these are separate from CF7’s spam settings and need to be configured independently.
- Once satisfied, deactivate CF7. Check that no front-end errors appear.
- Deactivate and delete the migrator plugin. It’s a one-time tool.
A Note on Email Notifications
The notification migration is probably the most valuable thing the plugin does beyond the basic field transfer — it’s also the thing most likely to need a quick review after migration.
CF7’s email notifications are fairly simple: one plain-text email (or HTML if you enabled it), with all configuration in one place. Gravity Forms’ notification system is richer: you can have multiple notifications per form, each with different recipients, conditions, and formats. The migrator creates one notification from CF7’s Mail tab (“Admin Notification”) and, if Mail 2 was active, a second one (“User Confirmation”).
The merge tag translation handles the common cases well, but there are a few things to check:
The Reply-To header. CF7 typically has Reply-To: [your-email] in its additional headers. The migrator extracts this and populates GF’s Reply-To field, converting [your-email] to the correct GF merge tag. Check that this looks right in the notification settings.
HTML emails. If you had CF7’s “Use HTML content type” checked for Mail 2, the body was probably HTML. The migrator copies the body text as-is, including any HTML. Check that GF’s notification is set to HTML format if the body contains markup.
Multiple recipients. CF7 supports comma-separated recipients. GF does too, so these should transfer fine, but it’s worth checking if your forms had multiple recipients.
Conditional notifications. CF7 doesn’t support conditional notifications (send this notification only if field X has value Y). If you need conditional notifications, you’ll need to set those up manually in GF after migration.
The Complete Plugin
Here’s the full plugin code. Drop it into a folder called cf7-to-gf-migrator inside your wp-content/plugins directory, then activate it from the Plugins screen.
The complete, ready-to-install file is attached below:
Extending the Plugin
If you’re a developer who wants to adapt this for your own needs, here are the most common extension points:
Adding a new field type mapping. The cf7gf_field_mapper() function returns a registry of closures. Adding a new type is as simple as adding an entry:
'my-custom-type' => fn( $tag ) => [
[
'type' => 'gf_equivalent_type',
'label' => 'whatever',
],
[], // no warnings
],
Modifying the default confirmation message. In cf7gf_build_gf_form(), find the 'confirmations' key and change the 'message' value. You could also extract this from CF7’s “Messages” tab ($cf7_form->prop('messages')) if you wanted to carry it over.
Migrating entries. The plugin deliberately skips entries — CF7 stores them via Flamingo as custom post types, and GF has its own entry format. Bridging them is possible but complicated, and honestly most migrations don’t need it (CF7 entries are available as a read-only archive in Flamingo if you need them). If you do want to attempt it, GFAPI::add_entry() is your friend on the GF side.
Running programmatically. If you want to trigger a migration via WP-CLI or a custom script rather than the admin UI, the business logic is cleanly separated in cf7gf_migrate_one_form() and cf7gf_build_gf_form(). You can call them directly with a CF7 post ID.
Handling custom CF7 add-on fields. Some CF7 add-ons register their own field types (e.g. [number-slider], [star-rating]). If these appear in your forms, they’ll be logged as warnings and skipped. You can add them to the mapper registry if GF has an equivalent — either core or via a GF add-on.
I find it slightly annoying that after all these years there’s still no official migration path between these two very popular WordPress form plugins. CF7’s install count is enormous, GF is the obvious upgrade destination for many of those sites, and yet anyone who wants to make the move has to do it by hand or find a third-party solution. Hopefully this helps fill that gap.
If you’re looking to extend Gravity Forms with repeatable field groups, check out the Gravity Forms Repeater Plugin — it adds a native repeater field to Gravity Forms, letting users dynamically add rows of fields within a single form submission.
The plugin as written handles everything I needed for a real client migration. It won’t cover every edge case that exists across the enormous variety of CF7 setups out there, but it handles the common field types, real labels, and email notifications well. And if it hits something it can’t handle, it tells you about it in the warnings column rather than silently producing a broken form.