Castlegate IT WP Schema Map
A lightweight WordPress plugin that maps existing site content to Schema.org structured data output.
by Castlegate IT · github.com/castlegateit/cgit-wp-schema-map · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/castlegateit/cgit-wp-schema-map/archive/refs/heads/main.zipDeclares an update source (https://github.com/castlegateit/cgit-wp-schema-map), so updates arrive through the plugin's own updater.
Readme
CGIT WP Schema Map
A lightweight WordPress plugin that maps existing site content to Schema.org structured data output. It wraps spatie/schema-org for JSON-LD generation while abstracting away the structural boilerplate of complex schema types.
This is a companion to cgit-wp-schema, not a replacement. Where cgit-wp-schema owns and registers post types with schema baked in, cgit-wp-schema-map is designed to retrofit schema onto existing content structures — arbitrary post types, ACF fields, flex content blocks — without prescribing how that content is managed.
Requirements
- PHP 8.2+
Admin bar
When viewing the front end as a logged-in user with the manage_options capability, a Schema menu appears in the admin bar listing every schema type output on the current page. Two links are provided at the bottom of the menu to validate the page's structured data in a new tab:
- Validate with Schema.org — validator.schema.org
- Test with Google Rich Results — search.google.com/test/rich-results
To change the required capability, use the cgit_wp_schema_map_admin_bar_capability filter:
add_filter('cgit_wp_schema_map_admin_bar_capability', fn() => 'edit_posts');
Namespaces
Schema types are grouped into namespaces that reflect the Schema.org hierarchy:
| Namespace | Classes |
|---|---|
Castlegate\SchemaMap\Schema\Article |
Article, NewsArticle, BlogPosting |
Castlegate\SchemaMap\Schema\Organization |
Organization, LocalBusiness, FoodEstablishment |
Castlegate\SchemaMap\Schema\Product |
Product, Vehicle |
Castlegate\SchemaMap\Schema |
Everything else |
Nested schema types
Several properties require a fully-formed schema instance rather than a plain string. Schema.org does not allow text values for these properties, and passing a string where a schema instance is required will cause a PHP type error.
| Property | Valid schema types |
|---|---|
author (Article, Book) |
Person or Organization |
publisher (Article, Book) |
Organization or Person |
provider (Course) |
Organization or Person |
organizer (Event) |
Organization or Person |
seller (Offer) |
Organization or Person |
worksFor (Person) |
Organization |
manufacturer (Product, Vehicle) |
Organization |
aggregateRating (Organization, LocalBusiness, Product, Vehicle) |
AggregateRating |
addOffer() (Product, Vehicle) |
Offer |
addReview() (LocalBusiness) |
Review |
The exception is location on Event, which accepts either a plain string (venue name) or a Place instance — schema.org allows text for this property.
Registering schema
Call Registry::register() anywhere that runs before wp_head — typically the template file that renders the content. The callback receives the current $post object.
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Article\Article;
if (class_exists(Registry::class)) {
Registry::register(function ($post) {
if (!is_singular('post')) {
return null;
}
$article = new Article();
$article->headline($post->post_title);
$article->datePublished($post->post_date);
return $article;
});
}
Returning null, or returning a schema object whose isEmpty() check fails, suppresses output for that registration. There is no separate targeting system — use conditions inside the callback or rely on which template file the registration lives in.
Multiple schemas on one page
Register separate callbacks for each schema block. All registrations run on every request and each independently decides whether to output:
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\BreadcrumbList;
use Castlegate\SchemaMap\Schema\Organization\Organization;
use Castlegate\SchemaMap\Schema\Article\Article;
if (class_exists(Registry::class)) {
Registry::register(function () {
$breadcrumb = new BreadcrumbList();
$breadcrumb->addItem('Home', home_url('/'));
$breadcrumb->addItem(get_the_title(), get_permalink());
return $breadcrumb;
});
Registry::register(function () {
$org = new Organization();
$org->name(get_bloginfo('name'));
$org->url(home_url());
return $org;
});
Registry::register(function ($post) {
if (!is_singular('post')) {
return null;
}
$article = new Article();
$article->headline($post->post_title);
$article->datePublished($post->post_date);
return $article;
});
}
FaqPage
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\FaqPage;
if (class_exists(Registry::class)) {
Registry::register(function ($post) {
$faq = new FaqPage();
foreach (get_field('faqs', $post->ID) ?: [] as $item) {
$faq->addQuestion(
$item['question'],
wp_strip_all_tags($item['answer'])
);
}
return $faq;
});
}
Merging across templates
FaqPage implements MergeableSchema. If multiple registrations on the same page return a FaqPage, the Registry automatically merges all questions into a single output block. This means templates can add their own questions without knowing about each other.
header.php — site-wide FAQ registered on every page:
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\FaqPage;
if (class_exists(Registry::class)) {
Registry::register(function ($post) {
$faq = new FaqPage();
$faq->addQuestion('Where are you based?', 'We are based in York, UK.');
$faq->addQuestion('Do you work remotely?', 'Yes, we work with clients across the UK.');
return $faq;
});
}
single-service.php — service-specific questions:
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\FaqPage;
if (class_exists(Registry::class)) {
Registry::register(function ($post) {
$faq = new FaqPage();
foreach (get_field('service_faqs', $post->ID) ?: [] as $item) {
$faq->addQuestion($item['question'], $item['answer']);
}
return $faq;
});
}
Both registrations contribute questions and the output is a single merged FAQPage block. If no questions are added to a FaqPage instance, isEmpty() returns true and it is silently dropped.
Article hierarchy
Article, NewsArticle, and BlogPosting share the same base class. All Article setters are available on the subtypes.
Article
├── NewsArticle
└── BlogPosting
Article
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Article\Article;
use Castlegate\SchemaMap\Schema\Organization\Organization;
use Castlegate\SchemaMap\Schema\Person;
if (class_exists(Registry::class)) {
Registry::register(function ($post) {
if (!is_singular('post')) {
return null;
}
$author = new Person();
$author->name(get_the_author_meta('display_name', $post->post_author));
$author->url(get_author_posts_url($post->post_author));
$publisher = new Organization();
$publisher->name(get_bloginfo('name'));
$publisher->url(home_url());
$publisher->logo(get_field('logo_url', 'option'));
$article = new Article();
$article->headline($post->post_title);
$article->datePublished($post->post_date); // MySQL datetime string accepted
$article->dateModified($post->post_modified); // converted to ISO 8601 automatically
$article->description(get_the_excerpt($post));
$article->url(get_permalink($post));
$article->image(get_the_post_thumbnail_url($post, 'full'));
$article->keywords(implode(', ', wp_get_post_tags($post->ID, ['fields' => 'names'])));
$article->articleBody(apply_filters('the_content', $post->post_content)); // HTML stripped automatically
$article->author($author);
$article->publisher($publisher);
return $article;
});
}
NewsArticle
Adds print publication metadata on top of all Article properties.
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Article\NewsArticle;
if (class_exists(Registry::class)) {
$article = new NewsArticle();
$article->headline($post->post_title);
$article->datePublished($post->post_date);
$article->dateModified($post->post_modified);
$article->author($author);
$article->publisher($publisher);
// NewsArticle-specific fields:
$article->dateline('York');
$article->printEdition('Morning Edition');
$article->printSection('Business');
$article->printPage('3');
$article->printColumn('2');
}
BlogPosting
BlogPosting uses the same setters as Article — the only difference is the @type emitted in the JSON-LD output, which signals to search engines that the content is a blog post.
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Article\BlogPosting;
if (class_exists(Registry::class)) {
$post_schema = new BlogPosting();
$post_schema->headline($post->post_title);
$post_schema->datePublished($post->post_date);
$post_schema->dateModified($post->post_modified);
$post_schema->url(get_permalink($post));
$post_schema->image(get_the_post_thumbnail_url($post, 'full'));
}
Organization hierarchy
Organization, LocalBusiness, and FoodEstablishment form a cascade. Every property available on Organization is also available on LocalBusiness, and every property on LocalBusiness is available on FoodEstablishment.
Organization
└── LocalBusiness
└── FoodEstablishment
Organization
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Organization\Organization;
if (class_exists(Registry::class)) {
$org = new Organization();
$org->name(get_bloginfo('name'));
$org->url(home_url());
$org->logo(get_field('logo_url', 'option'));
$org->telephone(get_field('telephone', 'option'));
$org->email(get_field('email', 'option'));
$org->faxNumber(get_field('fax', 'option'));
$org->description(get_bloginfo('description'));
$org->image(get_field('og_image', 'option'));
$org->sameAs([
get_field('twitter_url', 'option'),
get_field('linkedin_url', 'option'),
get_field('facebook_url', 'option'),
]);
}
sameAs accepts either a single string or an array. Empty or null values in the array are ignored by spatie at render time.
LocalBusiness
Adds address, geo coordinates, opening hours, price range, and reviews.
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Organization\LocalBusiness;
if (class_exists(Registry::class)) {
$business = new LocalBusiness();
// Inherits all Organization setters:
$business->name(get_bloginfo('name'));
$business->url(home_url());
$business->telephone(get_field('telephone', 'option'));
$business->logo(get_field('logo_url', 'option'));
// LocalBusiness-specific:
$business->streetAddress(get_field('address_street', 'option'));
$business->addressLocality(get_field('address_city', 'option'));
$business->postalCode(get_field('address_postcode', 'option'));
$business->addressCountry('GB');
$business->geo(53.9600, -1.0873);
$business->openingHours([
'Mo-Tu 09:00-17:30',
'We 09:00-13:00',
'Th-Fr 09:00-17:30',
]);
$business->priceRange('££');
}
Address components are only emitted when at least one is set — an empty PostalAddress block is never output. Geo requires both latitude and longitude; providing only one suppresses the GeoCoordinates block entirely.
FoodEstablishment
Extends LocalBusiness with cuisine, menu URL, and reservation support. All Organization and LocalBusiness setters work directly.
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Organization\FoodEstablishment;
if (class_exists(Registry::class)) {
$restaurant = new FoodEstablishment();
// Organization properties:
$restaurant->name('The Grill at York');
$restaurant->url(home_url());
$restaurant->telephone('+44 1904 000000');
$restaurant->sameAs(['https://instagram.com/thegrillatyork']);
// LocalBusiness properties:
$restaurant->streetAddress('12 High Street');
$restaurant->addressLocality('York');
$restaurant->postalCode('YO1 8AA');
$restaurant->addressCountry('GB');
$restaurant->geo(53.9600, -1.0873);
$restaurant->openingHours(['Mo-Su 12:00-22:00']);
$restaurant->priceRange('£££');
// FoodEstablishment properties:
$restaurant->servesCuisine('Modern British');
$restaurant->hasMenu(home_url('/menu'));
$restaurant->acceptsReservations(true);
}
Product hierarchy
Product
└── VehicleRead the full README on GitHub →
Releases
These releases are tags only. The author does not attach a packaged zip, so there are no download counts to report.