Local Business Schema Markup Auto Updated Ratings
One area many local businesses overlook is review data within their schema markup. While new reviews continue to come in on Google, the review information embedded on their website often remains outdated for months or even years.
As AI search engines and crawlers analyze websites, they may pick up old ratings and review counts if the schema isn’t being refreshed regularly. This can result in outdated information appearing in search results, AI-generated answers, and other search experiences.
The ideal solution is to automate review updates directly within your schema markup.
I’ve developed a simple PHP code snippet for WordPress that automatically pulls your latest Google Business Profile rating and review count using your Google Places API key and Place ID. Once installed, your website’s schema stays updated in the background without any manual work.
This helps ensure that when Google, ChatGPT, Gemini, Perplexity, and other AI systems crawl your website, they’re seeing your most current review data, increasing the likelihood of accurate ratings being displayed in search results and AI-generated mentions.
Your Google reviews change every day. Your schema markup should too.
Code is here:
/**
* Basit M. Local and Service Schema Markup
*/
add_action('admin_menu', 'schema_global_settings_page');
function schema_global_settings_page() {
add_options_page(
'Global Schema Settings',
'Global Schema',
'manage_options',
'global-schema-settings',
'schema_global_settings_content'
);
}
function schema_global_settings_content() {
?>
<div class="wrap">
<h2>Global Schema Settings</h2>
<p>Values set here become **STRICT DEFAULTS** and will disable the corresponding field in the page/post editor. Leave a field empty if you wish to set it page-by-page.</p>
<form method="post" action="options.php">
<?php
settings_fields('schema_global_settings_group');
do_settings_sections('global-schema-settings');
submit_button();
?>
</form>
</div>
<?php
}
add_action('admin_init', 'schema_global_settings_init');
function schema_global_settings_init() {
register_setting('schema_global_settings_group', 'schema_global_settings', 'schema_sanitize_global_settings');
// Define the full list of Business Types
$business_types = [
"LocalBusiness","AnimalShelter","ArchiveOrganization","AutomotiveBusiness","ChildCare","Dentist",
"DryCleaningOrLaundry","EmergencyService","EmploymentAgency","EntertainmentBusiness","FinancialService",
"FoodEstablishment","GovernmentOffice","HealthAndBeautyBusiness","HomeAndConstructionBusiness","InternetCafe",
"LegalService","Library","LodgingBusiness","MedicalBusiness","ProfessionalService","RadioStation","RealEstateAgent",
"RecyclingCenter","SelfStorage","ShoppingCenter","SportsActivityLocation","Store","TelevisionStation",
"TouristInformationCenter","TravelAgency"
];
// --- Local Business Fields ---
add_settings_section('schema_local_business_section', 'Local Business Defaults (Strict)', null, 'global-schema-settings');
// Use select renderer for businessType
add_settings_field(
'businessType',
'Business Type',
'schema_render_global_select',
'global-schema-settings',
'schema_local_business_section',
['id' => 'businessType', 'options' => $business_types]
);
// The rest of the local fields (UPDATED WITH NEW REQUESTED FIELDS)
$local_fields = [
'name' => 'Business Name',
'legalName' => 'Legal Name', // ADDED
'alternateName' => 'Alternate Name', // ADDED
'url' => 'Official URL',
'logo' => 'Logo URL',
'image_urls' => 'Image URLs (comma/newline separated)', // ADDED - Handled as textarea
'priceRange' => 'Price Range (e.g., $$)', // ADDED
'telephone' => 'Main Telephone',
'description' => 'Description',
'streetAddress' => 'Street Address',
'addressLocality' => 'Locality/City',
'addressRegion' => 'Region/State',
'postalCode' => 'Postal Code',
'addressCountry' => 'Country',
'latitude' => 'Latitude',
'longitude' => 'Longitude',
'map' => 'Map URL (e.g., Google Maps URL)', // ADDED
'sameAs' => 'SameAs URLs (comma/newline separated)', // ADDED - Handled as textarea
'keywords' => 'Keywords (comma separated)', // ADDED - Handled as textarea
'google_place_id' => 'Google Place ID',
'google_api_key' => 'Google API Key',
// EXISTING GLOBAL FIELDS FOR MANUAL RATING IN LOCAL BUSINESS
'manual_rating_value' => 'Manual Rating Value (Local)',
'manual_review_count' => 'Manual Review Count (Local)',
];
foreach ($local_fields as $id => $label) {
add_settings_field($id, $label, 'schema_render_global_input', 'global-schema-settings', 'schema_local_business_section', ['id' => $id]);
}
// --- Opening Hours Fields (ADDED) ---
$days = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'];
foreach ($days as $day) {
add_settings_field(
"open_{$day}",
ucfirst($day) . ' Open',
'schema_render_global_input',
'global-schema-settings',
'schema_local_business_section',
['id' => "open_{$day}", 'type' => 'time']
);
add_settings_field(
"close_{$day}",
ucfirst($day) . ' Close',
'schema_render_global_input',
'global-schema-settings',
'schema_local_business_section',
['id' => "close_{$day}", 'type' => 'time']
);
}
// --- Service/Product Fields (EXISTING) ---
add_settings_section('schema_product_section', 'Service/Product Defaults (Strict)', null, 'global-schema-settings');
$product_fields = [
'product_name' => 'Service/Product Name',
'product_description' => 'Service/Product Description',
'product_url' => 'Service/Product URL',
'product_keywords' => 'Service/Product Keywords (comma sep.)',
'brand_name' => 'Brand/Organization Name',
'google_api_key' => 'Google API Key (Product)',
'google_place_id' => 'Google Place ID (Product)',
'product_image_urls' => 'Service/Product Image URLs (newline sep.)',
// EXISTING GLOBAL FIELDS FOR MANUAL RATING IN PRODUCT/SERVICE
'manual_rating_value_product' => 'Manual Rating Value (Product)',
'manual_review_count_product' => 'Manual Review Count (Product)',
];
foreach ($product_fields as $id => $label) {
add_settings_field($id, $label, 'schema_render_global_input', 'global-schema-settings', 'schema_product_section', ['id' => $id]);
}
}
function schema_render_global_input($args) {
$options = get_option('schema_global_settings');
$value = $options[$args['id']] ?? '';
$id = $args['id'];
// Use a textarea for multi-line/comma-separated input fields
if (in_array($id, ['product_image_urls', 'image_urls', 'sameAs', 'keywords'])) {
$placeholder = ($id === 'image_urls' || $id === 'product_image_urls')
? "Enter one URL per line or separate by commas."
: "Enter comma or newline separated values.";
?>
<textarea id="<?php echo esc_attr($id); ?>"
name="schema_global_settings[<?php echo esc_attr($id); ?>]"
class="regular-text widefat" rows="4"
placeholder="<?php echo esc_attr($placeholder); ?>"><?php echo esc_textarea($value); ?></textarea>
<?php
} elseif (strpos($id, 'open_') === 0 || strpos($id, 'close_') === 0) {
// Time input for opening hours
?>
<input type="time" id="<?php echo esc_attr($id); ?>"
name="schema_global_settings[<?php echo esc_attr($id); ?>]"
value="<?php echo esc_attr($value); ?>" class="regular-text" style="width: 150px;" />
<?php
} elseif (strpos($id, 'manual_rating_value') !== false) {
// Use number input with step for rating
?>
<input type="number" id="<?php echo esc_attr($id); ?>"
name="schema_global_settings[<?php echo esc_attr($id); ?>]"
value="<?php echo esc_attr($value); ?>" class="regular-text widefat" step="0.1" min="0" max="5" placeholder="e.g. 4.9" />
<?php
} elseif (strpos($id, 'manual_review_count') !== false) {
// Use number input for review count
?>
<input type="number" id="<?php echo esc_attr($id); ?>"
name="schema_global_settings[<?php echo esc_attr($id); ?>]"
value="<?php echo esc_attr($value); ?>" class="regular-text widefat" step="1" min="0" placeholder="e.g. 100" />
<?php
} else {
// Default text input
?>
<input type="text" id="<?php echo esc_attr($id); ?>"
name="schema_global_settings[<?php echo esc_attr($id); ?>]"
value="<?php echo esc_attr($value); ?>" class="regular-text widefat" />
<?php
}
}
/**
* Renders a select dropdown for global settings (used for businessType).
*/
function schema_render_global_select($args) {
$options = get_option('schema_global_settings');
$value = $options[$args['id']] ?? '';
$select_options = $args['options'] ?? [];
?>
<select id="<?php echo esc_attr($args['id']); ?>"
name="schema_global_settings[<?php echo esc_attr($args['id']); ?>]"
class="regular-text widefat">
<option value="">Leave empty for page-level editing</option>
<?php foreach ($select_options as $option) : ?>
<option value="<?php echo esc_attr($option); ?>" <?php selected($value, $option); ?>>
<?php echo esc_html($option); ?>
</option>
<?php endforeach; ?>
</select>
<?php
}
/**
* Sanitizes all global settings fields (UPDATED FOR NEW FIELDS).
*/
function schema_sanitize_global_settings($input) {
$new_input = [];
$textarea_fields = ['product_image_urls', 'image_urls', 'sameAs', 'keywords', 'product_keywords'];
foreach ($input as $key => $value) {
if (in_array($key, $textarea_fields)) {
$new_input[$key] = sanitize_textarea_field($value);
} elseif (strpos($key, 'manual_rating_value') !== false) {
$new_input[$key] = floatval($value); // Sanitize as float for rating
} elseif (strpos($key, 'manual_review_count') !== false) {
$new_input[$key] = intval($value); // Sanitize as integer for review count
} else {
$new_input[$key] = sanitize_text_field($value); // Default for all others (text, url, time, etc.)
}
}
return $new_input;
}
// --- LOCAL BUSINESS META BOX (Updated to check for all new global fields) ---
add_action('add_meta_boxes', function () {
add_meta_box('custom_schema_box', 'Local Business Schema Settings',
'render_custom_schema_box', ['page'], 'normal', 'high');
});
function render_custom_schema_box($post) {
$business_types = [
"LocalBusiness","AnimalShelter","ArchiveOrganization","AutomotiveBusiness","ChildCare","Dentist",
"DryCleaningOrLaundry","EmergencyService","EmploymentAgency","EntertainmentBusiness","FinancialService",
"FoodEstablishment","GovernmentOffice","HealthAndBeautyBusiness","HomeAndConstructionBusiness","InternetCafe",
"LegalService","Library","LodgingBusiness","MedicalBusiness","ProfessionalService","RadioStation","RealEstateAgent",
"RecyclingCenter","SelfStorage","ShoppingCenter","SportsActivityLocation","Store","TelevisionStation",
"TouristInformationCenter","TravelAgency"
];
$global = get_option('schema_global_settings', []);
$global_get = fn($k) => trim($global[$k] ?? '');
echo '<table class="form-table">';
// Business Type Field - Check if strictly set globally
$global_type = $global_get('businessType');
$is_type_strict = !empty($global_type);
echo '<tr><th><label for="schema_businessType">Business Type</label></th><td>';
if ($is_type_strict) {
echo '<p class="description">This field is locked globally: <strong>' . esc_html($global_type) . '</strong></p>';
} else {
$saved_type = get_post_meta($post->ID, '_schema_businessType', true);
echo '<select name="schema_businessType" id="schema_businessType" class="widefat">';
echo '<option value="">Select Business Type</option>';
foreach ($business_types as $type) {
$selected = selected($saved_type, $type, false);
echo "<option value='$type' $selected>$type</option>";
}
echo '</select>';
echo '<br><input type="text" name="schema_businessType_manual" id="schema_businessType_manual" value="' . esc_attr($saved_type) . '" class="widefat" placeholder="Or enter custom Business Type" />';
}
echo '</td></tr>';
// UPDATED FIELD LIST to include all new local fields and ensure all global fields are checked for strictness
$fields = [
'name', 'legalName', 'alternateName', 'url', 'logo', 'image_urls', // image_urls is the new multi-line field
'priceRange', 'telephone', 'description',
'streetAddress', 'addressLocality', 'addressRegion', 'postalCode', 'addressCountry',
'latitude', 'longitude', 'map', 'sameAs', 'keywords', // These now respect global strictness
'manual_rating_value', 'manual_review_count', 'google_place_id', 'google_api_key'
];
foreach ($fields as $field) {
$global_value = $global_get($field);
$value = get_post_meta($post->ID, '_schema_' . $field, true);
// Determine if field should be disabled
$is_strict_global = !empty($global_value);
$note = $is_strict_global ? " <strong>(STRICT Global: " . esc_html($global_value) . ")</strong>" : '';
// If strict global is set, render disabled view
$label_text = ucfirst(str_replace('_', ' ', $field));
echo '<tr><th><label for="schema_' . $field . '">' . $label_text . $note . '</label></th>';
echo '<td>';
if ($is_strict_global) {
echo '<p class="description">This field is locked globally: <strong>' . esc_html($global_value) . '</strong></p>';
// Always render a hidden input to save the page-level value (in case it was previously set)
echo '<input type="hidden" name="schema_' . $field . '" value="' . esc_attr($value) . '" />';
} elseif ($field === 'manual_rating_value') {
echo "<input type='number' name='schema_$field' id='schema_$field' value='" . esc_attr($value) . "' class='widefat' step='0.1' min='0' max='5' placeholder='e.g. 4.9' />";
} elseif ($field === 'manual_review_count') {
echo "<input type='number' name='schema_$field' id='schema_$field' value='" . esc_attr($value) . "' class='widefat' step='1' min='0' placeholder='e.g. 100' />";
} elseif (in_array($field, ['sameAs', 'keywords', 'image_urls'])) {
// Use textarea for multi-line/comma-separated page-level fields
$placeholder = ($field === 'image_urls')
? 'Enter one URL per line or separate by commas.'
: 'Enter comma or newline separated values.';
echo "<textarea name='schema_$field' id='schema_$field' class='widefat' rows='3' placeholder='$placeholder'>" . esc_textarea($value) . "</textarea>";
} else {
// Otherwise, render a standard input for page-level editing
echo '<input type="text" name="schema_' . $field . '" id="schema_' . $field . '" value="' . esc_attr($value) . '" class="widefat" />';
}
echo '</td></tr>';
}
// Opening Hours (Updated to respect global strictness)
$days = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'];
foreach ($days as $day) {
$open_key = "open_{$day}";
$close_key = "close_{$day}";
$global_open = $global_get($open_key);
$global_close = $global_get($close_key);
$is_open_strict = !empty($global_open);
$is_close_strict = !empty($global_close);
$open = get_post_meta($post->ID, "_schema_$open_key", true);
$close = get_post_meta($post->ID, "_schema_$close_key", true);
echo "<tr><th>" . ucfirst($day) . "</th><td>";
// Open Time
if ($is_open_strict) {
echo "Opens: <p class='description'>Locked: <strong>" . esc_html($global_open) . "</strong></p>";
echo "<input type='hidden' name='schema_$open_key' value='" . esc_attr($open) . "' />";
} else {
echo "Opens: <input type='time' name='schema_$open_key' value='$open' />";
}
// Close Time
if ($is_close_strict) {
echo "Closes: <p class='description'>Locked: <strong>" . esc_html($global_close) . "</strong></p>";
echo "<input type='hidden' name='schema_$close_key' value='" . esc_attr($close) . "' />";
} else {
echo "Closes: <input type='time' name='schema_$close_key' value='$close' />";
}
echo "</td></tr>";
}
echo '</table>';
wp_nonce_field('save_schema_fields', 'schema_nonce');
}
// --- LOCAL BUSINESS SAVE HOOK ---
add_action('save_post', function ($post_id) {
if (!isset($_POST['schema_nonce']) || !wp_verify_nonce($_POST['schema_nonce'], 'save_schema_fields')) return;
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
$global = get_option('schema_global_settings', []);
$global_get = fn($k) => trim($global[$k] ?? ''); // Helper function
// Handle Business Type save only if not globally strict
if (empty($global_get('businessType'))) {
$business_type = sanitize_text_field($_POST['schema_businessType_manual'] ?? $_POST['schema_businessType'] ?? '');
update_post_meta($post_id, '_schema_businessType', $business_type);
}
// Save all non-strict page-level fields (including new ones)
foreach ($_POST as $key => $val) {
if (strpos($key, 'schema_') === 0 && $key !== 'schema_businessType_manual') {
$meta_key_suffix = str_replace('schema_', '', $key);
// Do not save if a strict global value exists for this field (checks only local keys for Local Business fields)
if (empty($global_get($meta_key_suffix))) {
$sanitized_val = '';
if (strpos($meta_key_suffix, 'manual_rating_value') !== false) {
$sanitized_val = floatval($val);
} elseif (strpos($meta_key_suffix, 'manual_review_count') !== false) {
$sanitized_val = intval($val);
} elseif (in_array($meta_key_suffix, ['image_urls', 'sameAs', 'keywords', 'product_image_urls'])) {
$sanitized_val = sanitize_textarea_field($val);
} else {
$sanitized_val = sanitize_text_field($val);
}
update_post_meta($post_id, '_' . $key, $sanitized_val);
}
}
}
}, 10, 1);
// --- PRODUCT SCHEMA META BOX (Existing) ---
add_action('add_meta_boxes', function () {
add_meta_box('product_schema_box', 'Service/Product Schema Settings', 'render_product_schema_box', ['page'], 'normal', 'high');
});
function render_product_schema_box($post) {
$fields = [
'product_name', 'product_description', 'product_url', 'product_keywords',
'brand_name', 'google_api_key', 'google_place_id',
'manual_rating_value', 'manual_review_count',
'product_image_urls'
];
$global = get_option('schema_global_settings', []);
// Global get function for product/service fields, checks product-specific keys first
$global_get_product = fn($k) => trim($global[$k.'_product'] ?? '') ?: trim($global[$k] ?? '');
echo '<table class="form-table">';
foreach ($fields as $field) {
// Use the combined global check here to see if the page-level field should be locked
$global_value = $global_get_product($field);
$value = get_post_meta($post->ID, '_schema_' . $field, true);
$is_strict_global = !empty($global_value);
$note = $is_strict_global ? " <strong>(STRICT Global: " . esc_html($global_value) . ")</strong>" : '';
$label = str_replace('Product', 'Service', ucwords(str_replace('_', ' ', $field)));
echo "<tr><th><label for='schema_$field'>$label $note</label></th><td>";
if ($is_strict_global) {
echo '<p class="description">This field is locked globally: <strong>' . esc_html($global_value) . '</strong></p>';
echo '<input type="hidden" name="schema_' . $field . '" value="' . esc_attr($value) . '" />';
}
elseif ($field === 'product_image_urls') {
echo "<textarea id='schema_$field' name='schema_$field' class='widefat' rows='4' placeholder='Enter one image URL per line. Single images work too.'>" . esc_textarea($value) . "</textarea>";
}
elseif ($field === 'manual_rating_value') {
echo "<input type='number' id='schema_$field' name='schema_$field' value='" . esc_attr($value) . "' class='widefat' step='0.1' min='0' max='5' placeholder='e.g. 4.9'>";
} elseif ($field === 'manual_review_count') {
echo "<input type='number' id='schema_$field' name='schema_$field' value='" . esc_attr($value) . "' class='widefat' step='1' min='0' placeholder='e.g. 100'>";
} else {
echo "<input type='text' id='schema_$field' name='schema_$field' value='" . esc_attr($value) . "' class='widefat'>";
}
echo "</td></tr>";
}
echo '</table>';
$offers = get_post_meta($post->ID, '_schema_offers', true);
if (!is_array($offers)) $offers = [];
?>
<h3>Service Offers (Always Page-Specific)</h3>
<div id="schema-offers-container">
<?php foreach ($offers as $i => $offer) : ?>
<div class="offer-item" style="margin-bottom:10px;border:1px solid #ddd;padding:10px;">
<input type="text" name="schema_offers[<?php echo $i; ?>][name]" value="<?php echo esc_attr($offer['name'] ?? ''); ?>" placeholder="Offer Name" style="width:30%;margin-right:5px;">
<input type="text" name="schema_offers[<?php echo $i; ?>][price]" value="<?php echo esc_attr($offer['price'] ?? ''); ?>" placeholder="Price" style="width:30%;margin-right:5px;">
<input type="text" name="schema_offers[<?php echo $i; ?>][currency]" value="<?php echo esc_attr($offer['currency'] ?? ''); ?>" placeholder="Currency (e.g., USD)" style="width:30%;">
<button type="button" class="remove-offer button" style="margin-left:5px;">Remove</button>
</div>
<?php endforeach; ?>
</div>
<button type="button" id="add-offer" class="button">+ Add Offer</button>
<script>
document.addEventListener('DOMContentLoaded', function() {
const container = document.getElementById('schema-offers-container');
document.getElementById('add-offer').addEventListener('click', function() {
const count = container.querySelectorAll('.offer-item').length;
const div = document.createElement('div');
div.className = 'offer-item';
div.style.marginBottom = '10px';
div.style.border = '1px solid #ddd';
div.style.padding = '10px';
div.innerHTML = `
<input type="text" name="schema_offers[${count}][name]" placeholder="Offer Name" style="width:30%;margin-right:5px;">
<input type="text" name="schema_offers[${count}][price]" placeholder="Price" style="width:30%;margin-right:5px;">
<input type="text" name="schema_offers[${count}][currency]" placeholder="Currency (e.g., USD)" style="width:30%;">
<button type="button" class="remove-offer button" style="margin-left:5px;">Remove</button>
`;
container.appendChild(div);
div.querySelector('.remove-offer').addEventListener('click', function() {
div.remove();
});
});
document.querySelectorAll('.remove-offer').forEach(btn => {
btn.addEventListener('click', function() {
this.parentElement.remove();
});
});
});
</script>
<?php
wp_nonce_field('save_product_schema_fields', 'product_schema_nonce');
}
// --- PRODUCT SCHEMA SAVE HOOK (Existing with minor adjustment) ---
add_action('save_post', function ($post_id) {
if (!isset($_POST['product_schema_nonce']) || !wp_verify_nonce($_POST['product_schema_nonce'], 'save_product_schema_fields')) return;
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
$global = get_option('schema_global_settings', []);
foreach ($_POST as $key => $val) {
if (strpos($key, 'schema_') === 0 && $key !== 'schema_offers') {
$meta_key_suffix = str_replace('schema_', '', $key);
// Global check for Product/Service fields: checks product-specific key first
$global_check_key = in_array($meta_key_suffix, ['manual_rating_value', 'manual_review_count', 'google_api_key', 'google_place_id'])
? $meta_key_suffix . '_product'
: $meta_key_suffix;
// Only save if no strict global value exists
if (empty($global[$global_check_key])) {
if ($meta_key_suffix === 'product_image_urls') {
$sanitized_val = sanitize_textarea_field($val);
} elseif (strpos($meta_key_suffix, 'manual_rating_value') !== false) {
$sanitized_val = floatval($val);
} elseif (strpos($meta_key_suffix, 'manual_review_count') !== false) {
$sanitized_val = intval($val);
} else {
$sanitized_val = sanitize_text_field($val);
}
update_post_meta($post_id, '_' . $key, $sanitized_val);
}
}
}
if (isset($_POST['schema_offers']) && is_array($_POST['schema_offers'])) {
$offers = array_map(function($offer) {
return [
'name' => sanitize_text_field($offer['name'] ?? ''),
'price' => sanitize_text_field($offer['price'] ?? ''),
'currency' => sanitize_text_field($offer['currency'] ?? ''),
];
}, $_POST['schema_offers']);
update_post_meta($post_id, '_schema_offers', array_filter($offers));
} else {
delete_post_meta($post_id, '_schema_offers');
}
}, 11, 1); // Changed priority to 11 to avoid conflict with the local business save hook at 10
function get_google_rating_data($place_id, $api_key) {
if (!$place_id || !$api_key) return null;
$url = "https://maps.googleapis.com/maps/api/place/details/json?place_id=" . urlencode($place_id) . "&key=" . urlencode($api_key);
$response = wp_remote_get($url);
if (is_wp_error($response)) return null;
$data = json_decode(wp_remote_retrieve_body($response), true);
if (isset($data['result']['rating']) && isset($data['result']['user_ratings_total'])) {
return [
'ratingValue' => $data['result']['rating'],
'reviewCount' => $data['result']['user_ratings_total']
];
}
return null;
}
function fetch_google_place_data($api_key, $place_id) {
return get_google_rating_data($place_id, $api_key);
}
/**
* UPDATED: Fetches opening hours respecting global strict defaults.
*/
function get_opening_hours_schema() {
global $post;
$get = get_schema_data_retriever();
$days = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'];
$output = [];
foreach ($days as $day) {
// Use $get to retrieve global setting if strict, otherwise post meta
$open = $get("open_{$day}");
$close = $get("close_{$day}");
if ($open && $close) {
$output[] = [
"@type" => "OpeningHoursSpecification",
"dayOfWeek" => ucfirst($day),
"opens" => $open,
"closes" => $close,
];
}
}
return $output ?: null; // Return null if empty for clean schema
}
function get_schema_data_retriever() {
global $post;
$global = get_option('schema_global_settings', []);
$global_get = fn($k) => trim($global[$k] ?? '');
// STRICT FALLBACK: Global value takes precedence if set. Otherwise, use post meta.
return fn($k) => $global_get($k) ?: trim(get_post_meta($post->ID, '_schema_' . $k, true));
}
add_action('wp_head', function () {
if (!is_singular()) return;
global $post;
$get = get_schema_data_retriever();
// --- Local Business Schema Generation ---
if ($get('name') && $get('url')) {
// Handle new multi-line/comma-separated fields
$sameAs_raw = $get('sameAs');
$sameAs_temp = preg_split('/[\n,]/', $sameAs_raw, -1, PREG_SPLIT_NO_EMPTY);
$sameAs = array_filter(array_map('trim', $sameAs_temp));
$keywords_raw = $get('keywords');
$keywords = $keywords_raw ? array_filter(array_map('trim', explode(',', $keywords_raw))) : null;
$image_urls_raw = $get('image_urls');
$images_temp = preg_split('/[\n,]/', $image_urls_raw, -1, PREG_SPLIT_NO_EMPTY);
$images = array_filter(array_map('trim', $images_temp));
// Retrieve API keys and Place IDs, falling back to global settings
$api_key = $get('google_api_key');
$place_id = $get('google_place_id');
$rating_data = null;
if ($api_key && $place_id) {
$rating_data = get_google_rating_data($place_id, $api_key);
}
// Manual/Aggregate Rating Logic (Order: Google API -> Global Manual -> Post Meta Manual -> Default)
$rating_value = $rating_data['ratingValue'] ?? ($get('manual_rating_value') ?: '5.0');
$review_count = $rating_data['reviewCount'] ?? ($get('manual_review_count') ?: '49');
// Business type: Uses strict global (if set) OR page-level meta OR default
$business_type = $get('businessType') ?: get_post_meta($post->ID, '_schema_businessType', true) ?: "ProfessionalService";
// Helper to clean array of empty fields
$clean_empty_fields = function(array $arr) use (&$clean_empty_fields) {
return array_filter($arr, function($value) use ($clean_empty_fields) {
if (is_array($value)) {
$value = $clean_empty_fields($value);
return !empty($value);
}
return $value !== '' && $value !== null;
});
};
$local_schema = [
"@context" => "http://schema.org",
"@type" => $business_type,
"name" => $get('name'),
"legalName" => $get('legalName'), // ADDED
"alternateName" => $get('alternateName'), // ADDED
"url" => $get('url'),
"logo" => $get('logo'),
"image" => $images ?: null, // Uses the new multi-url field
"priceRange" => $get('priceRange'), // ADDED
"telephone" => $get('telephone'),
"description" => $get('description'),
"address" => array_filter([
"@type" => "PostalAddress",
"streetAddress" => $get('streetAddress'),
"addressLocality" => $get('addressLocality'),
"addressRegion" => $get('addressRegion'),
"postalCode" => $get('postalCode'),
"addressCountry" => $get('addressCountry'),
]),
"geo" => array_filter([
"@type" => "GeoCoordinates",
"latitude" => $get('latitude'),
"longitude" => $get('longitude'),
]),
"hasMap" => $get('map'), // ADDED
"sameAs" => $sameAs ?: null, // ADDED
"keywords" => $keywords, // ADDED
"openingHoursSpecification" => get_opening_hours_schema(), // UPDATED function call
"aggregateRating" => [
"@type" => "AggregateRating",
"ratingValue" => $rating_value,
"reviewCount" => $review_count
]
];
$local_schema = $clean_empty_fields($local_schema);
// Output Local Business Schema
echo '<script type="application/ld+json">' . json_encode($local_schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . '</script>';
}
// --- Service/Product Schema Generation (Existing) ---
$required_fields = ['product_name', 'product_description', 'product_url', 'product_image_urls'];
$is_product_schema_enabled = true;
foreach ($required_fields as $field) {
if (empty($get($field))) {
$is_product_schema_enabled = false;
break;
}
}
if ($is_product_schema_enabled) {
$keywords_raw = $get('product_keywords');
$keywords = $keywords_raw ? array_map('trim', explode(',', $keywords_raw)) : [];
$images_raw = $get('product_image_urls');
$images = array_filter(array_map('trim', explode("\n", $images_raw)));
// Product-specific check for API keys/Place IDs, favoring product-specific global keys
$global_settings = get_option('schema_global_settings', []);
$api_key = $global_settings['google_api_key_product'] ?? $get('google_api_key');
$place_id = $global_settings['google_place_id_product'] ?? $get('google_place_id');
$product_schema = [
"@context" => "https://schema.org",
"@type" => "Product",
"additionalType" => "https://schema.org/Service",
"name" => $get('product_name'),
"description" => $get('product_description'),
"url" => $get('product_url'),
"keywords" => $keywords ?: null,
"image" => $images ?: null,
"brand" => [
"@type" => "Organization",
"name" => $get('brand_name'),
],
];
$ratingValue = null;
$reviewCount = null;
if (!empty($api_key) && !empty($place_id)) {
$google_data = fetch_google_place_data($api_key, $place_id);
if ($google_data && $google_data['ratingValue'] && $google_data['reviewCount']) {
$ratingValue = $google_data['ratingValue'];
$reviewCount = $google_data['reviewCount'];
}
}
// Check Manual Ratings (Order: Global Product Manual -> Global Local Manual -> Post Meta Manual)
if (!$ratingValue || !$reviewCount) {
// 1. Global Product Manual Ratings
$global_product_rating = $global_settings['manual_rating_value_product'] ?? null;
$global_product_review = $global_settings['manual_review_count_product'] ?? null;
// 2. Fallback to Global Local Manual Ratings
$global_local_rating = $global_settings['manual_rating_value'] ?? null;
$global_local_review = $global_settings['manual_review_count'] ?? null;
// 3. Fallback to Post Meta Manual Ratings
$post_meta_rating = get_post_meta($post->ID, '_schema_manual_rating_value', true);
$post_meta_review = get_post_meta($post->ID, '_schema_manual_review_count', true);
$manual_rating = floatval($global_product_rating ?: $global_local_rating ?: $post_meta_rating);
$manual_count = intval($global_product_review ?: $global_local_review ?: $post_meta_review);
if ($manual_rating > 0 && $manual_count > 0) {
$ratingValue = $manual_rating;
$reviewCount = $manual_count;
}
}
if ($ratingValue && $reviewCount) {
$product_schema['aggregateRating'] = [
"@type" => "AggregateRating",
"ratingValue" => $ratingValue,
"reviewCount" => $reviewCount
];
}
$offers = get_post_meta($post->ID, '_schema_offers', true);
if (is_array($offers) && !empty($offers)) {
$product_schema['offers'] = [];
foreach ($offers as $offer) {
if (!empty($offer['name']) && !empty($offer['price']) && !empty($offer['currency'])) {
$product_schema['offers'][] = [
"@type" => "Offer",
"name" => $offer['name'],
"price" => $offer['price'],
"priceCurrency" => $offer['currency']
];
}
}
}
$product_schema = $clean_empty_fields($product_schema);
// Output Product Schema
echo '<script type="application/ld+json">' . json_encode(
$product_schema,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
) . '</script>';
}
});
How to Configure the AI-Optimized Local Business & Service Schema Plugin
This schema system automatically generates Local Business and Service/Product schema markup for your website. It can also pull your Google Business Profile ratings and review count directly from Google, ensuring your schema stays updated automatically.
Step 1: Open Global Schema Settings
In WordPress Admin:
Settings → Global Schema
Anything entered here becomes a global default across your entire website.
If a field is filled globally, page-level editing for that field is automatically disabled.
Use Global Settings when:
- Business information is the same on every page
- You want consistent schema sitewide
- You don’t want editors accidentally changing important business details
Local Business Fields
Business Type
Choose the most accurate category.
Examples:
- ProfessionalService
- Dentist
- MedicalBusiness
- RealEstateAgent
- Store
- TravelAgency
- LocalBusiness
For most service businesses:
ProfessionalService is usually the safest option.
Business Name
Enter your public business name.
Example:
Focused Medical Billing
Legal Name
Enter the official registered company name.
Example:
Focused Medical Billing LLC
Alternate Name
Any commonly used variation.
Example:
FMB
Official URL
Homepage URL.
Example:
Logo URL
Direct image URL to your logo.
Example:
Image URLs
Add business photos.
You can add:
One image per line
OR
Comma-separated URLs
Example:
Price Range
Examples:
$
$$
$$$
$$$$
For service businesses:
$$ is usually sufficient.
Telephone
Main business phone number.
Example:
+1 555 123 4567
Description
Short description of the company.
Example:
Focused Medical Billing provides medical billing, revenue cycle management, and credentialing services for healthcare providers throughout the United States.
Address Information
Street Address
Example:
123 Main Street Suite 200
City
Example:
Houston
State / Region
Example:
Texas
Postal Code
Example:
77001
Country
Example:
US
Geo Coordinates
Latitude
Example:
29.7604
Longitude
Example:
-95.3698
You can obtain coordinates from Google Maps.
Map URL
Paste your Google Maps business URL.
Example:
Social Profiles (SameAs)
Add all social profiles.
One per line or comma separated.
Example:
Keywords
Enter important keywords.
Example:
medical billing, credentialing, revenue cycle management, healthcare billing
Google Review Automation
This is the most important section for AI optimization.
Google Place ID
Enter your Google Business Profile Place ID.
Example:
ChIJ1234567890ABCDEF
Google API Key
Enter your Google Places API key.
Example:
AIzaSyxxxxxxxxxxxxxxxxxxxxxxxx
What Happens?
When both fields are filled:
- Rating updates automatically
- Review count updates automatically
- AggregateRating schema stays current
- Google, ChatGPT, Gemini, Perplexity, and other AI crawlers see current review data
No manual updates required.
Manual Rating Backup
Only use these fields if you do NOT have a Google API key.
Manual Rating Value
Example:
4.9
Manual Review Count
Example:
257
Business Hours
For each day:
Enter:
Open Time
Close Time
Example:
Monday
Open: 09:00
Close: 17:00
Repeat for all business days.
Service/Product Schema
Used for service pages.
Example:
- Medical Billing
- Kitchen Remodeling
- Roofing
- HVAC Repair
- Plumbing
Service/Product Name
Example:
Medical Billing Services
Service/Product Description
Describe the service.
Example:
Professional medical billing services for private practices, clinics, and healthcare organizations.
Service/Product URL
URL of the service page.
Example:
Service Keywords
Example:
medical billing, revenue cycle management, insurance claims
Brand Name
Usually your business name.
Example:
Focused Medical Billing
Service Images
Add service-related images.
One image URL per line.
Offers
Each page can have multiple offers.
Example:
Offer Name: Starter Billing Package
Price: 299
Currency: USD
Offer Name: Full Revenue Cycle Management
Price: 999
Currency: USD
Recommended Setup
For best SEO and AI optimization:
Fill all business information globally
Add Google Place ID
Add Google API Key
Add social profiles
Add business hours
Add service-specific schema on service pages
Use multiple business images
Keep descriptions unique and detailed
This setup allows search engines and AI systems to understand your business, services, location, reputation, and reviews with maximum accuracy.
