Castlegate IT WP Schema Map WP Registry grade A. No security issues foundWP RegistryNo security issues foundOpen the reportA
A lightweight WordPress plugin that maps existing site content to Schema.org structured data output.
Install
This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/castlegateit/cgit-wp-schema-map/archive/refs/heads/main.zipA 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
└── Vehicle
Product
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Product\Product;
use Castlegate\SchemaMap\Schema\AggregateRating;
use Castlegate\SchemaMap\Schema\Offer;
if (class_exists(Registry::class)) {
$product = new Product();
$product->name(get_the_title());
$product->description(get_the_excerpt());
$product->url(get_permalink());
$product->image(get_the_post_thumbnail_url($post, 'full'));
$product->brand(get_field('brand'));
$product->sku(get_field('sku'));
$product->gtin(get_field('barcode'));
$product->color(get_field('colour'));
$product->category(get_field('category'));
$offer = new Offer();
$offer->price((float) get_field('price'));
$offer->priceCurrency('GBP');
$offer->inStock(); // convenience method
$offer->priceValidUntil(get_field('sale_ends')); // accepts MySQL datetime
$product->addOffer($offer);
}
Multiple offers can be added — call addOffer() more than once to represent different variants, price tiers, or regional pricing:
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Offer;
if (class_exists(Registry::class)) {
$standardOffer = new Offer();
$standardOffer->price(29.99);
$standardOffer->priceCurrency('GBP');
$standardOffer->inStock();
$premiumOffer = new Offer();
$premiumOffer->price(49.99);
$premiumOffer->priceCurrency('GBP');
$premiumOffer->inStock();
$product->addOffer($standardOffer);
$product->addOffer($premiumOffer);
}
Vehicle
All Product setters are available. Vehicle adds automotive-specific fields with named convenience methods for the enum properties.
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Product\Vehicle;
use Castlegate\SchemaMap\Schema\Offer;
if (class_exists(Registry::class)) {
$vehicle = new Vehicle();
// Product properties:
$vehicle->name(get_field('make') . ' ' . get_field('model'));
$vehicle->description(get_the_excerpt());
$vehicle->url(get_permalink());
$vehicle->image(get_the_post_thumbnail_url($post, 'full'));
$vehicle->brand(get_field('make'));
$vehicle->color(get_field('colour'));
// Vehicle-specific:
$vehicle->mileage((float) get_field('mileage'), 'SMI'); // SMI = statute miles; KMT = kilometres
$vehicle->numberOfDoors((int) get_field('doors'));
$vehicle->vehicleSeatingCapacity((float) get_field('seats'));
$vehicle->vehicleIdentificationNumber(get_field('vin'));
$vehicle->vehicleModelDate(get_field('model_year'));
$vehicle->dateVehicleFirstRegistered(get_field('registration_date'));
$vehicle->fuelType(get_field('fuel_type'));
$vehicle->bodyType(get_field('body_type'));
// Transmission — convenience methods avoid needing to know the Schema.org URL:
$vehicle->automaticTransmission();
// or: $vehicle->manualTransmission();
// or: $vehicle->vehicleTransmission('https://schema.org/AutomaticTransmission');
// Drive wheel configuration:
$vehicle->allWheelDrive();
// or: $vehicle->fourWheelDrive();
// or: $vehicle->frontWheelDrive();
// or: $vehicle->rearWheelDrive();
// or: $vehicle->driveWheelConfiguration('https://schema.org/FrontWheelDriveConfiguration');
$offer = new Offer();
$offer->price((float) get_field('price'));
$offer->priceCurrency('GBP');
$offer->inStock();
$vehicle->addOffer($offer);
}
Offer
Typically nested inside Product or Vehicle via addOffer(), but can also be registered standalone.
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Offer;
use Castlegate\SchemaMap\Schema\Organization\Organization;
if (class_exists(Registry::class)) {
$offer = new Offer();
$offer->price(0.0); // 0.0 is a valid price (free); null means price is absent
$offer->priceCurrency('GBP');
$offer->url(get_permalink());
$offer->priceValidUntil(get_field('offer_expires')); // MySQL datetime accepted
$seller = new Organization();
$seller->name(get_bloginfo('name'));
$seller->url(home_url());
$offer->seller($seller);
// Availability convenience methods:
$offer->inStock();
// $offer->outOfStock();
// $offer->preOrder();
// $offer->discontinued();
// $offer->limitedAvailability();
// $offer->onlineOnly();
// $offer->inStoreOnly();
// $offer->soldOut();
// Raw escape hatch for unusual values:
// $offer->availability('https://schema.org/InStock');
}
Event
eventStatus defaults to EventScheduled on construction — Google's required field is always present without the caller needing to set it. Named convenience methods replace the need to know Schema.org URL strings.
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Event;
use Castlegate\SchemaMap\Schema\Organization\Organization;
if (class_exists(Registry::class)) {
$event = new Event();
$event->name(get_the_title());
$event->startDate(get_field('event_start')); // MySQL datetime accepted
$event->endDate(get_field('event_end'));
$event->description(get_the_excerpt());
$event->url(get_permalink());
$event->image(get_the_post_thumbnail_url($post, 'full'));
// Event status — defaults to EventScheduled; override only when needed:
$event->cancelled();
// $event->postponed();
// $event->rescheduled();
// $event->moved();
// $event->scheduled(); // explicit reset to default
// Raw escape hatch:
// $event->eventStatus('https://schema.org/EventScheduled');
// Attendance mode:
$event->inPerson();
// $event->online();
// $event->hybrid();
$organizer = new Organization();
$organizer->name(get_bloginfo('name'));
$organizer->url(home_url());
$event->organizer($organizer);
}
Event location as a string
For simple venues where only the name is needed:
$event->location('The Grand Assembly Rooms, York');
Event location as a Place
Pass a fully-formed Place instance to give search engines address and geo data alongside the venue name:
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Place;
if (class_exists(Registry::class)) {
$venue = new Place();
$venue->name(get_field('venue_name'));
$venue->streetAddress(get_field('venue_street'));
$venue->addressLocality(get_field('venue_city'));
$venue->postalCode(get_field('venue_postcode'));
$venue->addressCountry('GB');
$venue->geo(
(float) get_field('venue_lat'),
(float) get_field('venue_lng')
);
$venue->telephone(get_field('venue_phone'));
$event->location($venue);
}
Place
Place is useful both as a standalone schema block and as the location value on an Event.
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Place;
if (class_exists(Registry::class)) {
$place = new Place();
$place->name(get_field('location_name'));
$place->description(get_field('location_description'));
$place->url(get_permalink());
$place->telephone(get_field('telephone'));
$place->streetAddress(get_field('street'));
$place->addressLocality(get_field('city'));
$place->postalCode(get_field('postcode'));
$place->addressCountry('GB');
$place->geo(53.9600, -1.0873);
$place->openingHours(['Mo-Fr 09:00-17:00', 'Sa 10:00-13:00']);
}
Person
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Person;
use Castlegate\SchemaMap\Schema\Organization\Organization;
if (class_exists(Registry::class)) {
$person = new Person();
$person->name(get_the_title());
$person->jobTitle(get_field('job_title'));
$person->description(get_field('bio'));
$person->image(get_the_post_thumbnail_url($post, 'full'));
$person->email(get_field('email'));
$person->telephone(get_field('direct_line'));
$person->url(get_permalink());
$person->sameAs([
get_field('linkedin_url'),
get_field('twitter_url'),
]);
$org = new Organization();
$org->name(get_bloginfo('name'));
$org->url(home_url());
$person->worksFor($org);
}
AggregateRating
Almost always used as a nested property of LocalBusiness, Organization, or Product rather than registered standalone.
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\AggregateRating;
use Castlegate\SchemaMap\Schema\Organization\LocalBusiness;
if (class_exists(Registry::class)) {
$rating = new AggregateRating();
$rating->ratingValue(4.8);
$rating->reviewCount(127); // written reviews only
$rating->ratingCount(214); // total ratings including star-only
$rating->bestRating(5.0);
$rating->worstRating(1.0);
$business = new LocalBusiness();
$business->name(get_bloginfo('name'));
$business->aggregateRating($rating);
}
reviewCount and ratingCount are distinct: reviewCount is the number of reviews that include written content, ratingCount is the total including those with only a star rating. Google requires at least one of the two.
Note: Google only shows AggregateRating rich results for ratings sourced from a legitimate third party — a Trustpilot or Google Reviews feed, for example. First-party self-aggregated testimonials are not eligible.
Review
Typically accumulated on a LocalBusiness via addReview(). Multiple calls are collected and output as an array.
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Review;
use Castlegate\SchemaMap\Schema\Organization\LocalBusiness;
if (class_exists(Registry::class)) {
$business = new LocalBusiness();
$business->name(get_bloginfo('name'));
foreach (get_field('reviews', 'option') ?: [] as $r) {
$review = new Review();
$review->author($r['name']); // string; wrapped in Person internally
$review->reviewBody($r['body']);
$review->ratingValue((float) $r['rating']);
$review->bestRating(5.0);
$review->worstRating(1.0);
$review->datePublished($r['date']); // MySQL datetime accepted
$review->url($r['source_url']);
$business->addReview($review);
}
}
The author field accepts a plain string name; it is wrapped in a Person with a name property internally — callers do not need to construct a Person instance.
ContentCapture
A utility for capturing rendered HTML from WordPress template parts or ACF flex content loops, for use with schema setters that accept HTML input such as articleBody().
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\ContentCapture;
use Castlegate\SchemaMap\Schema\Article\Article;
if (class_exists(Registry::class)) {
$article = new Article();
$article->headline($post->post_title);
$article->articleBody(ContentCapture::capture(function () {
get_template_part('template-parts/content');
}));
}
Works equally well with loops:
$article->articleBody(ContentCapture::capture(function () use ($post) {
foreach (get_field('flexible_content', $post->ID) ?: [] as $block) {
get_template_part('template-parts/blocks/' . $block['acf_fc_layout'], null, $block);
}
}));
articleBody() strips the captured HTML to plain text automatically — there is no need to call strip_tags() yourself.
Course
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Course;
use Castlegate\SchemaMap\Schema\Organization\Organization;
if (class_exists(Registry::class)) {
$provider = new Organization();
$provider->name(get_bloginfo('name'));
$provider->url(home_url());
$course = new Course();
$course->name(get_the_title());
$course->description(get_the_excerpt());
$course->url(get_permalink());
$course->image(get_the_post_thumbnail_url($post, 'full'));
$course->courseCode(get_field('course_code'));
$course->educationalCredentialAwarded(get_field('qualification'));
$course->inLanguage('en-GB');
$course->keywords(get_field('keywords'));
$course->teaches(get_field('learning_outcomes'));
$course->coursePrerequisites(get_field('prerequisites'));
$course->provider($provider);
}
Book
bookFormat has named convenience methods to avoid the need to know Schema.org URL strings.
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Book;
use Castlegate\SchemaMap\Schema\Person;
use Castlegate\SchemaMap\Schema\Organization\Organization;
if (class_exists(Registry::class)) {
$author = new Person();
$author->name(get_field('author_name'));
$author->url(get_field('author_url'));
$publisher = new Organization();
$publisher->name(get_field('publisher_name'));
$book = new Book();
$book->name(get_the_title());
$book->description(get_the_excerpt());
$book->url(get_permalink());
$book->image(get_the_post_thumbnail_url($post, 'full'));
$book->isbn(get_field('isbn'));
$book->numberOfPages((int) get_field('page_count'));
$book->inLanguage('en-GB');
$book->datePublished(get_field('publication_date'));
$book->author($author);
$book->publisher($publisher);
// Format convenience methods:
$book->hardcover();
// $book->paperback();
// $book->ebook();
// $book->audiobook();
// Raw escape hatch:
// $book->bookFormat('https://schema.org/Hardcover');
}
BreadcrumbList
Items are added in display order; 1-based position numbers are assigned automatically.
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\BreadcrumbList;
if (class_exists(Registry::class)) {
$breadcrumb = new BreadcrumbList();
$breadcrumb->addItem('Home', home_url('/'));
$breadcrumb->addItem('Services', home_url('/services'));
$breadcrumb->addItem('Web Design', home_url('/services/web-design'));
$breadcrumb->addItem(get_the_title(), get_permalink());
}
DefinedTerm
Useful for glossaries, taxonomies, or structured educational content where terms belong to a known vocabulary.
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\DefinedTerm;
if (class_exists(Registry::class)) {
$term = new DefinedTerm();
$term->name(get_the_title());
$term->description(get_field('definition'));
$term->url(get_permalink());
$term->termCode(get_field('term_code'));
$term->inDefinedTermSet('https://example.com/glossary');
}
Common patterns
LocalBusiness with rating and reviews
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Organization\LocalBusiness;
use Castlegate\SchemaMap\Schema\AggregateRating;
use Castlegate\SchemaMap\Schema\Review;
if (class_exists(Registry::class)) {
Registry::register(function () {
$rating = new AggregateRating();
$rating->ratingValue(4.9);
$rating->reviewCount(83);
$rating->bestRating(5.0);
$rating->worstRating(1.0);
$business = new LocalBusiness();
$business->name(get_bloginfo('name'));
$business->url(home_url());
$business->telephone(get_field('telephone', 'option'));
$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->aggregateRating($rating);
foreach (get_field('featured_reviews', 'option') ?: [] as $r) {
$review = new Review();
$review->author($r['author']);
$review->reviewBody($r['body']);
$review->ratingValue((float) $r['rating']);
$review->bestRating(5.0);
$review->worstRating(1.0);
$review->datePublished($r['date']);
$business->addReview($review);
}
return $business;
});
}
Article with full author and publisher
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Article\Article;
use Castlegate\SchemaMap\Schema\Person;
use Castlegate\SchemaMap\Schema\Organization\Organization;
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));
$author->image(get_avatar_url($post->post_author, ['size' => 400]));
$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);
$article->dateModified($post->post_modified);
$article->description(get_the_excerpt($post));
$article->articleBody(apply_filters('the_content', $post->post_content)); // HTML stripped automatically
$article->url(get_permalink($post));
$article->image(get_the_post_thumbnail_url($post, 'full'));
$article->author($author);
$article->publisher($publisher);
return $article;
});
}
Cancelled event with rescheduled date
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Event;
if (class_exists(Registry::class)) {
$event = new Event();
$event->name(get_the_title());
$event->startDate(get_field('new_date')); // updated date
$event->rescheduled(); // sets eventStatus to EventRescheduled
$event->inPerson();
$event->location(get_field('venue_name'));
}
Product not yet in stock
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Product\Product;
use Castlegate\SchemaMap\Schema\Offer;
if (class_exists(Registry::class)) {
$offer = new Offer();
$offer->price((float) get_field('price'));
$offer->priceCurrency('GBP');
$offer->preOrder();
$offer->priceValidUntil(get_field('launch_date'));
$product = new Product();
$product->name(get_the_title());
$product->addOffer($offer);
}
Vehicle with full metadata
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Product\Vehicle;
use Castlegate\SchemaMap\Schema\Offer;
if (class_exists(Registry::class)) {
Registry::register(function ($post) {
if (!is_singular('vehicle')) {
return null;
}
$offer = new Offer();
$offer->price((float) get_field('asking_price'));
$offer->priceCurrency('GBP');
$offer->inStock();
$vehicle = new Vehicle();
$vehicle->name(get_field('make') . ' ' . get_field('model'));
$vehicle->description(get_the_excerpt());
$vehicle->url(get_permalink());
$vehicle->image(get_the_post_thumbnail_url($post, 'full'));
$vehicle->brand(get_field('make'));
$vehicle->color(get_field('colour'));
$vehicle->sku(get_field('stock_number'));
$vehicle->mileage((float) get_field('mileage'), 'SMI');
$vehicle->numberOfDoors((int) get_field('doors'));
$vehicle->vehicleIdentificationNumber(get_field('vin'));
$vehicle->vehicleModelDate(get_field('year'));
$vehicle->dateVehicleFirstRegistered(get_field('first_reg'));
$vehicle->fuelType(get_field('fuel'));
$vehicle->bodyType(get_field('body_style'));
$vehicle->automaticTransmission();
$vehicle->allWheelDrive();
$vehicle->addOffer($offer);
return $vehicle;
});
}
FoodEstablishment pulling the full hierarchy
Because FoodEstablishment extends LocalBusiness which extends Organization, every setter from all three classes is available directly on the one instance. There is no need to build separate objects and nest them:
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Organization\FoodEstablishment;
use Castlegate\SchemaMap\Schema\AggregateRating;
use Castlegate\SchemaMap\Schema\Review;
if (class_exists(Registry::class)) {
Registry::register(function () {
$rating = new AggregateRating();
$rating->ratingValue(4.6);
$rating->reviewCount(312);
$rating->bestRating(5.0);
$rating->worstRating(1.0);
$restaurant = new FoodEstablishment();
// Organization-level
$restaurant->name(get_bloginfo('name'));
$restaurant->url(home_url());
$restaurant->telephone(get_field('telephone', 'option'));
$restaurant->email(get_field('email', 'option'));
$restaurant->logo(get_field('logo_url', 'option'));
$restaurant->sameAs([
get_field('instagram_url', 'option'),
get_field('tripadvisor_url', 'option'),
]);
$restaurant->aggregateRating($rating);
// LocalBusiness-level
$restaurant->streetAddress(get_field('address_street', 'option'));
$restaurant->addressLocality(get_field('address_city', 'option'));
$restaurant->postalCode(get_field('address_postcode', 'option'));
$restaurant->addressCountry('GB');
$restaurant->geo(53.9600, -1.0873);
$restaurant->openingHours([
'Mo-Fr 12:00-14:30',
'Mo-Fr 18:00-22:00',
'Sa-Su 12:00-22:00',
]);
$restaurant->priceRange('£££');
// FoodEstablishment-level
$restaurant->servesCuisine('Modern British');
$restaurant->hasMenu(home_url('/menu'));
$restaurant->acceptsReservations(true);
foreach (get_field('featured_reviews', 'option') ?: [] as $r) {
$review = new Review();
$review->author($r['author']);
$review->reviewBody($r['body']);
$review->ratingValue((float) $r['rating']);
$review->bestRating(5.0);
$review->worstRating(1.0);
$review->datePublished($r['date']);
$restaurant->addReview($review);
}
return $restaurant;
});
}
Sitewide schema in functions.php
Schema that belongs on every page — Organization, BreadcrumbList — is best registered once in functions.php or a site-wide include rather than in individual templates. Template-specific schema then layers on top:
// functions.php
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Organization\Organization;
use Castlegate\SchemaMap\Schema\BreadcrumbList;
if (class_exists(Registry::class)) {
// Organisation block on every page
Registry::register(function () {
$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->sameAs([
get_field('twitter_url', 'option'),
get_field('linkedin_url', 'option'),
]);
return $org;
});
// Breadcrumb on every page (structure determined per-template)
Registry::register(function ($post) {
$breadcrumb = new BreadcrumbList();
$breadcrumb->addItem('Home', home_url('/'));
if (is_singular()) {
if ($post->post_type !== 'page') {
$archive_url = get_post_type_archive_link($post->post_type);
$archive_label = get_post_type_object($post->post_type)->labels->name;
$breadcrumb->addItem($archive_label, $archive_url);
}
$breadcrumb->addItem(get_the_title($post), get_permalink($post));
}
return $breadcrumb;
});
}
Glossary page with multiple DefinedTerms
A single template can register multiple schemas by calling Registry::register() more than once. Each produces its own JSON-LD block:
// archive-glossary.php
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\DefinedTerm;
$terms = new WP_Query(['post_type' => 'glossary_term', 'posts_per_page' => -1]);
if (class_exists(Registry::class)) {
foreach ($terms->posts as $term_post) {
Registry::register(function () use ($term_post) {
$term = new DefinedTerm();
$term->name($term_post->post_title);
$term->description(get_field('definition', $term_post->ID));
$term->url(get_permalink($term_post));
$term->termCode(get_field('term_code', $term_post->ID));
$term->inDefinedTermSet(home_url('/glossary'));
return $term;
});
}
}
Edge cases
Empty schema is silently dropped
If no meaningful data has been set, isEmpty() returns true and the Registry omits the block entirely. There is no need to guard against empty output — a new Article() with no properties set produces no JSON-LD:
use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Article\Article;
if (class_exists(Registry::class)) {
Registry::register(function ($post) {
$article = new Article();
if ($headline = $post->post_title) {
$article->headline($headline);
}
return $article; // isEmpty() returns true if headline was never set
});
}
Null-safe field values
get_field() can return false, null, or an empty string. Setters accept string — passing false or null directly would cause a type error. The safest pattern is to guard before calling:
if ($telephone = get_field('telephone', 'option')) {
$business->telephone($telephone);
}
Or for values that should default gracefully to an empty string:
$business->telephone(get_field('telephone', 'option') ?: '');
// note: empty string is still set, which spatie will emit — prefer the guard above for optional fields
Partial address
If only some address components are set, only those are emitted inside the PostalAddress block — the block itself is not suppressed:
$business->postalCode('YO1 8AA');
$business->addressCountry('GB');
// PostalAddress is emitted with postalCode and addressCountry only
An address block is suppressed entirely only when all address components are null.
Partial geo is fully suppressed
Unlike address, geo requires both latitude and longitude. Setting only one means neither is emitted:
$business->geo(53.9600, null); // cannot pass null — type error
// Correct: call geo() only when both values are available
if ($lat && $lng) {
$business->geo((float) $lat, (float) $lng);
}
Free products (price of zero)
null and 0.0 are meaningfully different for Offer::price(). null means the p
This README is longer than the copy stored here. Read the rest on GitHub →
Releases
2 releases.
These releases are tags only. The author does not attach a packaged zip, so there are no download counts to report.