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. Stephen says

    Thanks for the awesome plugin.

    Please how do i add a “price” filter to your plugin.

    Thanks in advance.

  2. Marcin Biegun says

    hi! having troubles using data from wp-postratings (aggregateRating) to yoast,
    if you happened to play with that kind of rating – would be grateful for piece of code to compare.

    btw – commenting for the first time but getting landed there plenty of times while looking for acf/woocommerce solutions. thanks for great blog.

    • Bill Erickson says

      I’m not too familiar with that plugin, but based on briefly reviewing the code it looks like it’s designed for user ratings of your content, not you specifying a review rating to go along with your product review.

      I haven’t researched the requirements for adding aggregateRating schema yet so am not comfortable advising you on what to add, but if you find the documentation on the specific schema you want added, I’ll show you how to add it.

  3. Nicole says

    Is there a way to use this code to just remove the date published?
    I have disabled the published date in Genesis but it still appears via Yoast schema.

    • Bill Erickson says

      While you may be able to filter the schema and remove the published date, I don’t recommend it. Publish date is required for valid schema so you would make your schema invalid, losing all SEO benefit.

  4. Jason says

    Thanks Bill, top plugin!

    One thing I noticed and tried to change to no avail yet is the following:

    For the Review part of the graph the Author schema appears in Structured Data Testing tool like this:

    author
    @type Thing
    @id https://www.example.com/author/*wp-username*/
    name *Full author name*

    Yoast’s Article part of the graph actually shows the following (which is the correct way according to their documentation as it uses the unique ID of the Author and doesn’t make their username publicly available):

    author
    @type Person
    @id https://www.example.com/#/schema/person/12345678910
    name *Full author name*

    Furthermore the above generates a Person instead of a Thing.

    I’m assuming the following part of your plugin’s code needs to be amended:

    ‘author’ => array(
    ‘@id’ => get_author_posts_url( get_the_author_meta( ‘ID’ ) ),
    ‘name’ => get_the_author_meta( ‘display_name’, $post->post_author ),

    I haven’t figured it out yet so would greatly appreciate your input on this 🙂

  5. Tanner says

    This is all really helpful though i’m still unsure how to disable product review count and aggregate review score – I have custom code outputting that already and when yoast tries to do it it’s causing an error. Any help would be appreciated.

    • Bill Erickson says

      You want those fields disabled in the code provided in this post? Go to the generate() method and remove the elements you don’t want.

      But the product review schema won’t be valid without it. Even if something else is specifying a review count and aggregate review score, that likely won’t be tied to your custom product review schema.

  6. Todd Adams says

    Hey Bill, great article. I used this as a reference last year to develop a custom Speakable Schema plugin for one of my clients to inject specific Speakable Schema into Yoast. Worked fantastic up until the recent Yoast 14.0 update where they rewrote and changed all of the Schema https://developer.yoast.com/blog/yoast-seo-14-0-changing-the-yoast-schema-api/ I tried doing simple search/replaces for the changed parameters but it still causes critical errors for various reasons.

    Any plans to update this article in the future to apply to the new Yoast Schema structure?

  7. Andreas says

    Thank you bill for this plugin to extend the Yoast Schema functionality. The plugin outputs the snippet like this: Rating: 4,5 – ‎1 review
    This is aggregated rating: https://schema.org/AggregateRating

    However I would like to do editorial review posts where no one else is rating the product. The output in Google ideally should look like this: Rating: 4,5 – ‎Review by Author Name
    This is just rating: https://schema.org/Rating

    Is there an easy way to change the code from AggregateRating to Rating?

      • Andreas says

        Ok that’s easy, but it causes this error when validating with Google: “The property Rating is not recognized by Google for an object of type Product.”

        Also the offer is required, but I can add that as mentioned in the previous comment.

        • Bill Erickson says

          Right, that’s why we’re using AggregateRating, because it’s the recognized schema property for products.

          If you want to use a different type of schema, you should first look at Google’s structured data guide for the structure they recognize, then you can customize the output in Yoast SEO to match.

        • Andreas says

          Ok, like this it works:

          ‘reviewRating’ => array(
          ‘@type’ => ‘Rating’,
          ‘ratingValue’ =>

  8. Mads says

    Thanks a lot for going through the implementation like this. It helped me a lot with my own schema implementation.