Plugin Integration

AI Translation Pipelines With WPML

⏱ 17 min

Lesson 1 named the gap plainly: Course 6’s translation project links a translated draft to its source through a custom meta key, which WPML never sees. This lesson builds the WPML-native version of that same idea, an ability that generates a translated draft with the AI Client SDK, then registers it inside WPML’s own translation group so the language switcher, the admin translations column, and translated permalinks all recognize it correctly.

What you'll learn in this lesson
Using wpml_object_id as a duplicate guard
Checking whether a translation already exists in the target language before generating a new one.
Reading a source post's WPML language and translation group
wpml_post_language_details and wpml_element_trid, the two filters that tell you what you're translating from and into which group.
Registering a new post as a real WPML translation
The wpml_set_element_language_details action, the one hook that actually links two posts together in WPML's data.
Why icl_object_id() isn't part of this lesson's code
It's WPML's older function for the same lookup wpml_object_id's filter now performs, current WPML code should use the filter.
Prerequisites

WPML active, with Multilingual CMS or Multilingual Blog mode configured and at least two languages set up under WPML > Languages. Course 6’s translation-workflow project, this lesson reuses its draft-only, review-first behavior and changes how the translation is linked, not whether it publishes.

Step 1: Register the ability

Same input shape as Course 6’s project, a post ID and a target language, except here target_language is a WPML language code (es, fr, de) rather than a free-text language name, since that’s what every WPML hook in this lesson expects.

// File: wpml-translation-pipeline/wpml-translation-pipeline.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'wpml-translation-pipeline/translate-post',
		array(
			'label'               => __( 'Translate post via WPML', 'wpml-translation-pipeline' ),
			'description'         => __( 'Generates a translated draft of a post and registers it as a real WPML translation in the given language code. Returns the existing translation instead of duplicating it if one already exists.', 'wpml-translation-pipeline' ),
			'category'            => 'content',
			'input_schema'        => array(
				'type'       => 'object',
				'properties' => array(
					'post_id'         => array( 'type' => 'integer' ),
					'target_language' => array( 'type' => 'string', 'description' => 'A WPML language code, e.g. es, fr, de.' ),
				),
				'required'   => array( 'post_id', 'target_language' ),
			),
			'output_schema'       => array(
				'type'       => 'object',
				'properties' => array(
					'translated_post_id' => array( 'type' => 'integer' ),
					'edit_url'           => array( 'type' => 'string' ),
					'already_existed'    => array( 'type' => 'boolean' ),
				),
			),
			'permission_callback' => function ( $input ) {
				return current_user_can( 'edit_post', $input['post_id'] );
			},
			'execute_callback'    => 'wpml_translation_pipeline_translate',
			'meta' => array(
				'annotations' => array( 'destructive' => false ),
				'mcp'         => array( 'public' => true ),
			),
		)
	);
} );

Step 2: Check for an existing translation first

This is where wpml_object_id earns its place, not as a one-off lookup, but as the guard that stops this ability from generating a duplicate translation every time it’s called for a language pair that already has one.

// File: wpml-translation-pipeline/wpml-translation-pipeline.php
function wpml_translation_pipeline_translate( $input ) {
	$source = get_post( $input['post_id'] );
	if ( ! $source ) {
		return new WP_Error( 'not_found', 'Post not found.' );
	}

	$target_language = sanitize_text_field( $input['target_language'] );

	// The real, current WPML lookup: does a translation already exist in this language?
	$existing_id = apply_filters( 'wpml_object_id', $source->ID, $source->post_type, false, $target_language );

	if ( $existing_id ) {
		return array(
			'translated_post_id' => (int) $existing_id,
			'edit_url'           => get_edit_post_link( $existing_id, 'raw' ),
			'already_existed'    => true,
		);
	}

	// No translation yet, generate and link one.
	return wpml_translation_pipeline_create_translation( $source, $target_language );
}

Passing false for $return_original_if_missing matters here. With true, the filter would hand back the source post’s own ID when no translation exists, and this code would read that as “a translation already exists” and stop, never generating one. icl_object_id() performs the same underlying lookup, but it’s the older function this filter has superseded, new WPML integration code should call wpml_object_id directly.

// File: wpml-translation-pipeline/wpml-translation-pipeline.php
function wpml_translation_pipeline_create_translation( $source, $target_language ) {
	$prompt = <<<PROMPT
Translate the following post into the language identified by the code "{$target_language}".
Preserve meaning, tone, and paragraph structure, do not summarize or shorten it. Return the
translated title on the first line prefixed with "Title: ", then the translated body after a
blank line.

Title: {$source->post_title}
Content: {$source->post_content}
PROMPT;

	$response = wp_ai_client_prompt( $prompt )
		->using_model_preference( 'anthropic/claude', 'openai/gpt', 'google/gemini' )
		->generate_text();

	if ( empty( $response ) ) {
		return new WP_Error( 'empty_response', 'Translation failed, no draft was created.' );
	}

	list( $title, $body ) = wpml_translation_pipeline_split_title_body( $response, $source->post_title );

	$translated_id = wp_insert_post( array(
		'post_title'   => sanitize_text_field( $title ),
		'post_content' => wp_kses_post( $body ),
		'post_status'  => 'draft', // Same rule as Course 6's project, machine translation is never auto-published.
		'post_type'    => $source->post_type,
	), true );

	if ( is_wp_error( $translated_id ) ) {
		return $translated_id;
	}

	// Read the source post's own language and translation group, then register the new post
	// inside that same group in the target language.
	$source_language = apply_filters( 'wpml_post_language_details', null, $source->ID );
	$trid            = apply_filters( 'wpml_element_trid', null, $source->ID, 'post_' . $source->post_type );

	do_action( 'wpml_set_element_language_details', array(
		'element_id'           => $translated_id,
		'element_type'         => 'post_' . $source->post_type,
		'trid'                 => $trid,
		'language_code'        => $target_language,
		'source_language_code' => $source_language['language_code'],
	) );

	return array(
		'translated_post_id' => $translated_id,
		'edit_url'            => get_edit_post_link( $translated_id, 'raw' ),
		'already_existed'    => false,
	);
}

function wpml_translation_pipeline_split_title_body( $response, $fallback_title ) {
	if ( preg_match( '/^Title:\s*(.+)$/mi', $response, $m ) ) {
		return array( trim( $m[1] ), trim( str_replace( $m[0], '', $response ) ) );
	}
	return array( $fallback_title, $response );
}

wpml_element_trid returns the translation group ID (trid) the source post already belongs to, and wpml_set_element_language_details is the action that actually writes the new post into that group in the target language. Once this action runs, WPML’s own language switcher, the Translations column in wp-admin, and wpml_object_id lookups from anywhere else in your code all see the relationship immediately, none of it depends on a custom meta key.

Test it

wp-env run cli wp eval '
$result = wp_get_ability( "wpml-translation-pipeline/translate-post" )->execute( array(
	"post_id"         => 42,
	"target_language" => "es",
) );
print_r( $result );
'

Open the source post in wp-admin and confirm WPML’s own translation editor now lists a Spanish translation, then run the same command again with the same arguments and confirm already_existed comes back true the second time, with the same translated_post_id, rather than a second draft being created.

Skipping the trid lookup and letting WPML generate one implicitly

If you omit trid from the wpml_set_element_language_details call (or pass a stale one), WPML can create a new, separate translation group instead of adding the new post to the source post’s existing one. That produces a post that’s technically registered as a translation but not of the post you meant, always fetch a fresh trid from wpml_element_trid right before you call wpml_set_element_language_details, don’t cache or hardcode it.

Recap

This ability replaces Course 6’s meta-key linking with the real thing: wpml_object_id guards against generating a duplicate translation, wpml_post_language_details and wpml_element_trid read the source post’s language and translation group, and wpml_set_element_language_details writes the new draft into that group in the target language. The draft-only publishing behavior from Course 6 carries over unchanged, only the linking mechanism is now WPML’s own.

Resources & further reading

← A Full Multilingual AI Workflow AI Translation Pipelines With Polylang →