If you’ve ever installed a “Schema Pro” or “All In One Schema” plugin just to get a few extra structured data types onto your site, there’s a good chance you already had everything you needed. If your site runs Yoast SEO (as most well-built WordPress sites do), Yoast is already outputting a full JSON-LD schema graph on every page: WebPage, BreadcrumbList, WebSite, and your Organization or Person data. You don’t need a second plugin bolted on top of it. You need about twenty lines of PHP.

This is the same approach I use when a client needs a schema type that Yoast doesn’t generate automatically: Service schema on a service page, Product schema, or FAQPage on a page with a real Q&A section. It’s a hook, not a plugin, and it keeps your schema living in your theme where you can version it, test it, and see exactly what it’s doing.

Why skip the schema plugin

Dedicated schema plugins aren’t badly built, but they solve a problem you may not have. Most of them are designed to let non-developers configure structured data through a settings screen, which means they ship a generic UI for dozens of schema types you’ll never use, store their config in the database instead of your codebase, and often generate a second, separate JSON-LD block that duplicates parts of what your SEO plugin is already outputting. Two independent graphs on one page is exactly the kind of thing that produces the duplicate or conflicting structured data warnings you’ll see in Search Console.

If you’re already running Yoast, the cleaner fix is to extend the graph it’s already building rather than start a second one next to it.

How Yoast’s schema graph works

Yoast builds its JSON-LD as a single @graph array, where each entry is a “piece”: one for the WebPage, one for BreadcrumbList, one for the Organization, and so on. Under the hood, each piece is a PHP class that implements two methods: is_needed(), which decides whether that piece should appear on the current page, and generate(), which returns the array that becomes that piece’s JSON-LD.

Yoast exposes the full list of pieces through a filter, wpseo_schema_graph_pieces, which runs right before the graph is assembled. Add your own piece to that array, and it renders inline with everything Yoast already generates, sharing the same @id references (so your Service can point back at the same Organization node Yoast already built, instead of duplicating it).

Adding a custom schema piece, step by step

Here’s a working example that adds Service schema to a service page. Drop this in your theme’s functions.php or, better, a dedicated schema.php that you require from it.

add_filter( 'wpseo_schema_graph_pieces', function( $pieces, $context ) {
    $pieces[] = new Custom_Service_Schema_Piece( $context );
    return $pieces;
}, 11, 2 );

class Custom_Service_Schema_Piece implements Yoast\WP\SEO\Generators\Schema\Generator_Interface {

    private $context;

    public function __construct( $context ) {
        $this->context = $context;
    }

    public function is_needed() {
        // Only add this piece on the "service" post type or page template you're targeting.
        return is_singular( 'page' ) && is_page_template( 'template-service.php' );
    }

    public function generate() {
        return array(
            '@type'       => 'Service',
            '@id'         => $this->context->canonical . '#service',
            'name'        => get_the_title(),
            'description' => get_the_excerpt(),
            'url'         => $this->context->canonical,
            'provider'    => array( '@id' => $this->context->site_url . '#organization' ),
            'areaServed'  => 'US',
        );
    }
}

A few details worth noting since they’re the parts people usually get wrong:

  • The priority matters. Use 11 or later so your piece is added after Yoast has registered its own pieces, not before.
  • Reuse Yoast’s own @id values (available on the $context object) instead of inventing new ones. That’s what links your custom piece into the same graph instead of creating an orphaned node.
  • is_needed() is where your targeting logic lives. Keep it specific. A piece that fires on every page is how you end up with Service schema on your contact page.

Example: adding FAQPage schema from real content

The same pattern works for FAQPage schema, which is worth having on any page with a genuine, visible Q&A section. The important constraint here is that the schema has to match content that’s actually rendered on the page. Google has been explicit about this: FAQPage markup that doesn’t correspond to visible Q&A content on the page can be ignored or, in cases that look like manipulation, penalized.

add_filter( 'wpseo_schema_graph_pieces', function( $pieces, $context ) {
    $pieces[] = new Custom_FAQ_Schema_Piece( $context );
    return $pieces;
}, 11, 2 );

class Custom_FAQ_Schema_Piece implements Yoast\WP\SEO\Generators\Schema\Generator_Interface {

    private $context;

    public function __construct( $context ) {
        $this->context = $context;
    }

    public function is_needed() {
        return is_singular() && have_rows( 'faqs' );
    }

    public function generate() {
        $questions = array();

        while ( have_rows( 'faqs' ) ) {
            the_row();
            $questions[] = array(
                '@type'          => 'Question',
                'name'           => get_sub_field( 'question' ),
                'acceptedAnswer' => array(
                    '@type' => 'Answer',
                    'text'  => get_sub_field( 'answer' ),
                ),
            );
        }

        return array(
            '@type'      => 'FAQPage',
            '@id'        => $this->context->canonical . '#faq',
            'mainEntity' => $questions,
        );
    }
}

Notice the guard clause in is_needed(): have_rows( 'faqs' ) checks that there’s actual FAQ content on the page before the piece fires. That single line is what keeps this honest: the schema only appears when there’s something real behind it, and it disappears automatically on pages where the FAQ field is empty rather than requiring you to remember to toggle a setting per page.

Testing what you built

Before you ship it, check the output two ways:

  • View source, not DevTools. Right-click, View Page Source, and search for application/ld+json. This shows you what actually gets sent to crawlers, not what JavaScript has modified after the fact.
  • Google’s Rich Results Test. Paste the live URL in and confirm your new type shows up as a detected item with no errors. This also tells you if a required property is missing, which the browser console won’t.

Run this after every deploy that touches the schema filter, not just the first time. A theme update or a plugin conflict can silently break a custom piece, and unlike a visual bug, a broken schema piece produces no error a site owner will actually notice until search visibility drops.

When a plugin is still the right call

This approach is not a blanket argument against schema plugins. If you’re running an ecommerce catalog with hundreds of Product entries that need to stay in sync with inventory, or you need a non-technical editor to manage schema fields without touching code, a dedicated plugin (or your ecommerce platform’s built-in schema) is the more maintainable choice. The hook-based approach earns its keep when you have a handful of well-defined schema types tied to specific templates or fields, which describes most marketing and service-based WordPress sites.

The short version

If your SEO plugin already builds a schema graph, extend it with wpseo_schema_graph_pieces instead of layering a second plugin on top. Write a small class per schema type, gate it with a specific is_needed() check, reuse Yoast’s existing @id references, and verify the output with the Rich Results Test after every deploy. It’s less code than it sounds like, and it’s code you can actually read a year from now.

Need a hand auditing or fixing the structured data on your own WordPress site? See how I approach technical SEO for enterprise WordPress sites.

Categories

Technical SEO

Share on Social