I had a request where products of a post type product should be displayed under the URL path /product/product-name. So far, so good. However, a single product of this post type should be displayed via /portfolio-product-name.

Unfortunately, I couldn’t find any solution via Google. So I needed to start digging.

Since the user should select, under which slug the product is displayed, I added a custom postmeta field.

To achieve the desired behavior, I had to change two things: On the one hand, I needed to adjust the URL to the product, which is output via get_permalink and similar, e.g. on post archive pages. Luckily, you can use the filter post_type_link for that. So I just replaced the default slug in the link:

/**
 * Replace the permalink of a single post by a custom slug.
 * 
 * @param	string		$post_link The post permalink
 * @param	\WP_Post	$post The post object
 * @return	string The updated post permalink
 */
function my_replace_single_post_slug( string $post_link, WP_Post $post ): string {
	$custom_slug = get_field( 'slug', $post->ID );
	$default_slug = get_option( 'my_product_slug' ) ?? _x( 'product', 'post type slug', 'textdomain' );
	
	if ( $custom_slug ) {
		$post_link = str_replace( '/' . preg_quote( $default_slug ) . '/', '/' . $custom_slug . '/', $post_link );
	}
	
	return $post_link;
}

add_filter( 'post_type_link', 'my_replace_single_post_slug', 10, 2 );
Code language: PHP (php)

Since I’m also give the user the control over the default slug, it’s stored in the option my_product_slug here (or uses a default value).

On the other hand, and that was the tricky one, I needed to make this new URL working. Because WordPress doesn’t automatically redirect a link just because I changed its output. It needs to know what to do with it. This is achieved with so called “rewrite rules”.

Basically, for every individual slug of a product a new rewrite rule is getting created and added on top of the others so that it is being used in favor of the default ones. After that, the cached must be cleared:

/**
 * Set custom rewrite rules for post with custom slug.
 * 
 * @param	\WP_Post	$post The post object
 */
function my_set_rewrite_rules( WP_Post $post ): void {
	$custom_slug = get_field( 'slug', $post->ID );
	
	if ( ! $custom_slug ) {
		return;
	}
	
	$default_slug = mb_strtolower( _x( 'Products', 'Post Type General Name', 'textdomain' ) );
	$rules = get_option( 'rewrite_rules', [] );
	$rules_added = false;
	
	foreach ( $rules as $link => $rule ) {
		if ( strpos( $link, $default_slug ) === false ) {
			continue;
		}
		
		add_rewrite_rule( str_ireplace( $default_slug, $custom_slug, $link ), $rule, 'top' );
		$rules_added = true;
	}
	
	if ( $rules_added ) {
		flush_rewrite_rules( false );
	}
}

add_action( 'rest_after_insert_product', 'set_rewrite_rules' );
Code language: PHP (php)

Notice

Basically, this works as intended, but still a few notices regarding possible problems:

The second function uses the action rest_after_insert_product, which is only executed if the post is updated via API, which is the case for the Block Editor but not the classic editor. I couldn’t use the intended action wp_insert_post since the meta values, and in this case the custom slog (which is a meta value), still have the old value at this moment. So I would have to manually getting this value, which I would like to avoid.

If a custom slug is changed again, the old rewrite rules persist. One could solve this by just removing the old rewrite rule on changing the meta field.

Every other posts of this post type are also accessible via the custom slug. But since this custom slug is nowhere visible in the source code nor has an impact on their canonical URLs, this shouldn’t be a real problem.

Leave a Reply

Your email address will not be published. Required fields are marked *