Gravity Forms to CRM, WPML and ACF custom builds for a multilingual B2B site
A B2B site in 4 languages with 3,000 translated products, about 12 Gravity Forms feeding HubSpot and Salesforce, and an ACF-built Case Studies post type that editors can manage without a developer.
- Client
- B2B company with a 3,000-product WooCommerce catalogue
- My role
- WordPress and WooCommerce developer
- Gravity Forms
- HubSpot
- Salesforce
- WPML
- WCML
- Polylang
- ACF
- Action Scheduler
- Contact Form 7
- WPForms
- languages (EN/DE/FR/NL)
- 4
- products translated with WCML
- ≈ 3,000
- form-to-CRM delivery (from ≈ 97%)
- ≈ 99.6%
- URLs with hreflang validated
- ≈ 12,000
- ACF admin list load
- 3.4s → 0.9s
languages (EN/DE/FR/NL)
products translated with WCML
form-to-CRM delivery (from ≈ 97%)
URLs with hreflang validated
ACF admin list load
The challenge
A B2B client needed 4 languages (EN/DE/FR/NL), a 3,000-product WooCommerce catalogue, and about 12 Gravity Forms feeding HubSpot and Salesforce.
Editors also managed a custom "Case Studies" post type built entirely in ACF, and the whole site had to survive plugin updates without a developer.
What I built
- 01
Gravity Forms to CRM pipeline
Multi-step quote forms with conditional logic and file uploads, mapped to HubSpot deals and Salesforce leads. The submission handler is idempotent through an entry meta flag, so a retry never creates a duplicate CRM record. Failures are logged to a custom table and retried through Action Scheduler.
- 02
Contact Form 7 and WPForms equivalents
For smaller clients: CF7 to CRM through wpcf7_before_send_mail and WPForms through wpforms_process_complete, with spam control (honeypot, rate limit and hCaptcha) and per-form routing.
- 03
WPML with WooCommerce (WCML)
Translated products, variations and attributes, per-language currency, translated checkout and cart strings, language-aware permalinks, hreflang verification, and a language switcher that keeps the current archive filters.
- 04
Polylang on two other sites
String registration through pll_register_string, pll_get_post() for translated relationships, and a bilingual menu.
- 05
ACF-driven custom post type system
Field groups registered in PHP with acf_add_local_field_group and JSON sync enabled, an ACF options page for global settings, and acf/save_post logic that syncs selected fields into post meta and the search index.
- 06
ACF and WPML together
Field-level translation modes (Copy, Copy once, Translate) set deliberately per group, plus a get_field() wrapper that resolves the correct translated post ID.
Gotchas and how I fixed them
Register ACF field groups on acf/init
acf_add_local_field_group must run on acf/init, not init. Earlier and ACF isn't loaded; later and the field group may not attach to the editor.
ACF returns the wrong language in AJAX and REST
If you pass the untranslated post ID, ACF with WPML returns the wrong language in AJAX and REST contexts. Resolve the ID with wpml_object_id (WPML) or pll_get_post (Polylang) before calling get_field().
Translation mode is per field group
In WPML, "Copy" versus "Translate" changes what get_field() returns in the default language. Decide per field and document it; retro-fitting later is painful.
wpml_object_id needs the fallback flag
Pass $return_original_if_missing = true for products and variations that legitimately have no translation. Otherwise you get null and broken links.
CF7 sends mail even if the CRM call fails
wpcf7_before_send_mail gives you the submission object but the mail has not been sent. If your CRM call fails, the mail still goes. Decide which one is the source of truth and handle the other's failure explicitly.
Gravity Forms webhooks can fire more than once
Partial entries and webhooks can fire more than once on validation failures. Key the CRM write on entry_id, not on email.
Polylang strings in widgets and custom code
String registration is not automatic there. Use pll_register_string and pll__() or the string never appears in the translation UI.
Variations and WPML
Variation descriptions and per-variation images don't always sync. Test a variable product end to end in every language before go-live.
Code from the build
add_action( 'gform_after_submission', function ( $entry, $form ) {
$entry_id = (int) $entry['id'];
if ( gform_get_meta( $entry_id, 'crm_synced' ) ) {
return; // already pushed — retries are safe
}
$payload = [
'email' => rgar( $entry, '2' ),
'firstname' => rgar( $entry, '3' ),
'company' => rgar( $entry, '4' ),
'message' => rgar( $entry, '5' ),
'source' => 'gform:' . $form['id'],
];
$response = wp_remote_post( 'https://api.crm.example/v1/contacts', [
'timeout' => 15,
'headers' => [
'Authorization' => 'Bearer ' . MY_CRM_TOKEN,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode( $payload ),
] );
if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) >= 300 ) {
gform_update_meta( $entry_id, 'crm_error', wp_remote_retrieve_body( $response ) );
as_schedule_single_action( time() + 300, 'my_retry_crm_push', [ $entry_id ] );
return;
}
gform_update_meta( $entry_id, 'crm_synced', time() );
}, 10, 2 );The crm_synced flag keyed on the entry ID means a retry can never create a second CRM record, and failures go to a retry queue instead of vanishing.
function my_get_field_translated( string $selector, $post_id = null, $format = true ) {
$post_id = $post_id ?: get_the_ID();
if ( function_exists( 'wpml_object_id' ) && function_exists( 'wpml_get_current_language' ) ) {
$post_id = wpml_object_id( $post_id, get_post_type( $post_id ), true, wpml_get_current_language() );
} elseif ( function_exists( 'pll_get_post' ) ) {
$post_id = pll_get_post( $post_id, pll_current_language() ) ?: $post_id;
}
return get_field( $selector, $post_id, $format );
}One wrapper removed an entire class of "field is empty in German" bugs.
add_action( 'acf/init', function () {
if ( ! function_exists( 'acf_add_local_field_group' ) ) {
return;
}
acf_add_local_field_group( [
'key' => 'group_case_study',
'title' => 'Case Study',
'location' => [ [ [
'param' => 'post_type',
'operator' => '==',
'value' => 'case_study',
] ] ],
'fields' => [
[
'key' => 'field_cs_client',
'label' => 'Client',
'name' => 'client',
'type' => 'text',
],
[
'key' => 'field_cs_result',
'label' => 'Result',
'name' => 'result',
'type' => 'textarea',
],
],
] );
} );
add_filter( 'acf/settings/save_json', fn() => get_stylesheet_directory() . '/acf-json' );
add_filter( 'acf/settings/load_json', function ( $paths ) {
$paths[] = get_stylesheet_directory() . '/acf-json';
return $paths;
} );PHP registration plus JSON sync makes field groups diffable in git and impossible for an editor to delete.
Results
- 4 languages and about 3,000 products translated with WCML; hreflang validated (no duplicate hreflang and no self-referencing errors) across about 12,000 URLs.
- Gravity Forms: about 12 forms and about 800 submissions a month. CRM duplicates, previously about 5–10 a month, were eliminated after the idempotency flag.
- Form-to-CRM delivery rose from about 97% to about 99.6%. The remaining 0.4% are genuinely bad CRM payloads and now sit in a visible retry queue.
- Editors can add a language without a developer. New ACF field groups are registered in PHP and version-controlled, with zero "field disappeared after update" incidents in 12 months.
- About 15,000 ACF-managed posts; admin list-table load fell from about 3.4s to about 0.9s after replacing meta queries with a taxonomy for the main filter.
Questions people ask
How do I stop Gravity Forms creating duplicate contacts in HubSpot or Salesforce?
Set an entry meta flag once the CRM push succeeds and check it before every push. Key the write on entry_id, not email, because partial entries and webhooks can fire more than once. On one site this removed about 5–10 duplicates a month.
What happens when the CRM is down and a form is submitted?
Log the failure to a custom table and retry through Action Scheduler. Delivery on that site went from about 97% to about 99.6%. The remaining 0.4% are bad payloads that now show up in a retry queue.
Does ACF work correctly with WPML?
Yes, with two precautions. Set the translation mode (Copy, Copy once or Translate) deliberately per field group, and resolve the translated post ID with wpml_object_id before calling get_field(). Otherwise AJAX and REST requests can return the wrong language.
Can WPML translate a WooCommerce store with thousands of products?
Yes. On a 3,000-product catalogue in 4 languages, WCML handled products, variations, attributes, per-language currency and checkout strings. Test a variable product in every language before launch, because variation descriptions and images don't always sync.
How do I keep ACF field groups from disappearing after an update?
Register them in PHP with acf_add_local_field_group on acf/init and enable JSON sync. They are then version-controlled and editors can't delete them. That site had zero "field disappeared after update" incidents in 12 months.