Product Review Schema for Yoast SEO

Yoast SEO schema uses the Article  type for posts, but you can extend this using plugins or your own custom code.

In Google’s Structured Data Guidelines, they recommend:

Try to use the most specific applicable type and property names defined by schema.org for your markup.

I’m working on a product review website, so most articles are better classified with the Review type. I developed a plugin for:

  • Marking posts as reviews in the backend
  • Adding the additional review metadata to the post (in a custom metabox)
  • Updating the Yoast SEO schema to mark this as a review

The article below is a guide for developers extending Yoast SEO schema using product reviews as the example.

If you only want product review schema on your website, you can install my Product Review Schema for Yoast SEO plugin. You do not need to make any code changes.

Install the plugin

“Product Review Schema for Yoast SEO” is available for free on GitHub.

Download Now

Filtering schema pieces

The key to extending Yoast SEO schema is the wpseo_schema_graph_pieces filter.

This filter lets you add (or remove) pieces of the schema graph. Yoast SEO creates the large, structured graph by assembling all of these pieces.

/**
 * Adds Schema pieces to our output.
 *
 * @param array                 $pieces  Graph pieces to output.
 * @param \WPSEO_Schema_Context $context Object with context variables.
 *
 * @return array $pieces Graph pieces to output.
 */
public function schema_piece( $pieces, $context ) {
	require_once( plugin_dir_path( __FILE__ ) . '/class-be-product-review.php' );
	$pieces[] = new BE_Product_Review( $context );
	return $pieces;
}
add_filter( 'wpseo_schema_graph_pieces', array( $this, 'schema_piece' ), 20, 2 );

Each graph piece is packaged as a class. In this case, I’m using my custom BE_Product_Review class found in class-be-product-review.php in my plugin.

Creating a class

All graph piece classes should be implemented using the Abstract_Schema_Piece class.

use \Yoast\WP\SEO\Generators\Schema\Abstract_Schema_Piece;

class BE_Product_Review extends Abstract_Schema_Piece {

}

Your custom class should include two methods:

  1. generate() for generating the graph piece
  2. is_needed() for determining if this graph piece should be added

To make my customizations a bit more future-proof, I’ll extend an existing class rather than build my own. If a future update to Yoast SEO adds another required method, my class will automatically have it because I’m extending a core class that will also include it.

Extending an existing class

All of the Yoast SEO schema classes can be found in /wordpress-seo/src/generators/schema (see here). I’ll use the Article schema type as my base since a review is a more specific type of article.

use \Yoast\WP\SEO\Generators\Schema\Abstract_Schema_Piece;
use \Yoast\WP\SEO\Generators\Schema\Article;
use \Yoast\WP\SEO\Config\Schema_IDs;

class BE_Product_Review extends Article {

}

Customize is_needed()

Use this method to determine whether or not this graph piece is needed in this specific context.

Since I’m extending the Article class, I could leave this method out of my class and it would use is_needed() from the parent class. But my review schema doesn’t apply in every instance that the article schema would, so I need to define my own display logic.

For my BE_Product_Review class, I have a custom metabox that allows the editor to mark this as a product review using the be_product_review_include meta key.

Rather than using get_the_ID() to get the post ID, we’re using $this->context->id.

	/**
	 * Determines whether or not a piece should be added to the graph.
	 *
	 * @return bool
	 */
	public function is_needed() {
		$post_types = apply_filters( 'be_product_review_schema_post_types', array( 'post' ) );
		if( is_singular( $post_types ) ) {
			$display = get_post_meta( $this->context->id, 'be_product_review_include', true );
			if( 'on' === $display ) {
				return true;
			}
		}
		return false;
	}

Generate the graph data

The generate() method is where the actual magic happens. This is where you define the relevant schema data for this graph piece.

Make sure you read Google’s guidelines for your schema type, and use the Structured Data Testing Tool often to make sure you have everything correct.

In my case, the Product Review schema requirements include itemReviewed, reviewRating, reviewBody, and more.

When building your graph piece, it’s important that you include an @id so it can be referenced by other graph pieces, as well as isPartOf so Yoast SEO knows where to place it in the graph. In my case, the review is part of the article so I have:

'isPartOf' => array( '@id' => $this->context->canonical . Schema_IDs::ARTICLE_HASH ),

I added a filter to the end of my $data before returning it so plugins/themes can customize the output if needed.

To keep the generate() method as clean as possible, you can move logic to separate methods which are referenced inside this one.

I created the get_review_meta( $key, $fallback ) method for retrieving the review metadata, and providing a fallback in case these required fields haven’t been filled out (name = post title; review body = post content).

Below is the completed class. I recommend you look at the Product Review Schema for Yoast SEO plugin on GitHub for more information on how this all comes together:

<?php

use \Yoast\WP\SEO\Generators\Schema\Abstract_Schema_Piece;
use \Yoast\WP\SEO\Generators\Schema\Article;
use \Yoast\WP\SEO\Config\Schema_IDs;

class BE_Product_Review extends Article {

 	/**
	 * A value object with context variables.
	 *
	 * @var WPSEO_Schema_Context
	 */
	public $context;

	/**
	 * Determines whether or not a piece should be added to the graph.
	 *
	 * @return bool
	 */
	public function is_needed() {
		$post_types = apply_filters( 'be_product_review_schema_post_types', array( 'post' ) );
		if( is_singular( $post_types ) ) {
			$display = get_post_meta( $this->context->id, 'be_product_review_include', true );
			if( 'on' === $display ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Adds our Review piece of the graph.
	 *
	 * @return array $graph Review markup
	 */
	public function generate() {
		$post          = get_post( $this->context->id );
		$comment_count = get_comment_count( $this->context->id );

		$data          = array(
			'@type'            => 'Review',
			'@id'              => $this->context->canonical . '#product-review',
			'isPartOf'         => array( '@id' => $this->context->canonical . Schema_IDs::ARTICLE_HASH ),
			'itemReviewed'     => array(
					'@type'    => 'Product',
					'image'    => array(
						'@id'  => $this->context->canonical . Schema_IDs::PRIMARY_IMAGE_HASH,
					),
					'name'     => wp_strip_all_tags( $this->get_review_meta( 'name', get_the_title() ) ),
			),
			'reviewRating'     => array(
				'@type'        => 'Rating',
				'ratingValue'  => esc_attr( $this->get_review_meta( 'rating', 1 ) ),
			),
			'name'         => wp_strip_all_tags( $this->get_review_meta( 'name', get_the_title() ) ),
			'description' => wp_strip_all_tags( $this->get_review_meta( 'summary', get_the_excerpt( $post ) ) ),
			'reviewBody'  => wp_kses_post( $this->get_review_meta( 'body', $post->post_content ) ),
			'author'           => array(
				'@id'  => get_author_posts_url( get_the_author_meta( 'ID' ) ),
				'name' => get_the_author_meta( 'display_name', $post->post_author ),
			),
			'publisher'        => array( '@id' => $this->get_publisher_url() ),
			'datePublished'    => mysql2date( DATE_W3C, $post->post_date_gmt, false ),
			'dateModified'     => mysql2date( DATE_W3C, $post->post_modified_gmt, false ),
			'commentCount'     => $comment_count['approved'],
			'mainEntityOfPage' => $this->context->canonical . Schema_IDs::WEBPAGE_HASH,
		);
		$data = apply_filters( 'be_review_schema_data', $data, $this->context );

		return $data;
	}

	/**
	 * Determine the proper publisher URL.
	 *
	 * @return string
	 */
	private function get_publisher_url() {
		if ( $this->context->site_represents === 'person' ) {
			return $this->context->site_url . Schema_IDs::PERSON_HASH;
		}

		return $this->context->site_url . Schema_IDs::ORGANIZATION_HASH;
	}

	/**
	 * Product review meta
	 *
	 * @param string $key
	 * @param string $fallback
	 * @return string $meta
	 */
	private function get_review_meta( $key = false, $fallback = false ) {
		$meta = get_post_meta( $this->context->id, 'be_product_review_' . $key, true );
		if( empty( $meta ) && !empty( $fallback ) )
			$meta = $fallback;
		return $meta;
	}
}

Bill Erickson

Bill Erickson is the co-founder and lead developer at CultivateWP, a WordPress agency focusing on high performance sites for web publishers.

About Me
Ready to upgrade your website?

I build custom WordPress websites that look great and are easy to manage.

Let's Talk

Reader Interactions

Comments are closed. Continue the conversation with me on Twitter: @billerickson

Comments

  1. Bill Bennett says

    This works for me, but I get an warning from Google Search Console saying

    Missing field “review” (optional)

    It’s for this page:

    https://billbennett.co.nz/huawei-mate-10/

    I know it is optional, but how can I add this?

    There are other warnings, but this is the one that bothers me, I want Google to know this is a review.