Grav Google Indexing Plugin: automatically submit pages to Google

โก In short
Following the IndexNow plugin (Bing/Yandex), this second plugin completes the SEO pipeline by submitting modified pages directly to the Google Indexing API โ with no Composer dependency, using a RS256 JWT signed in pure PHP from a Google Cloud service account.
Source code available on GitHub:
- ๐ Plugin: jmrGrav/grav-plugin-google-indexing
๐ง Why
IndexNow covers Bing and Yandex, but Google does not support IndexNow. To notify Google instantly, you need to use its dedicated API: Google Indexing API v3.
Without this plugin:
- Google discovers new or modified pages only on its next crawl
- The delay can range from a few hours to several days
- No feedback on whether the submission was accepted
With this plugin, every save triggers an immediate submission with HTTP 200 confirmation in the logs.
๐ง What was done
๐ Plugin structure
/var/www/grav/user/plugins/google-indexing/
โโโ google-indexing.php โ main logic
โโโ blueprints.yaml โ admin field definitions
โโโ google-indexing.yaml โ default values๐ Google Cloud prerequisites
Four steps on the Google side before using the API:
- Create a Google Cloud project
- Enable the Indexing API in the project
- Create a Service Account and download the JSON credentials file
- Add the service account as Owner in Google Search Console
โ ๏ธ The role must be Owner โ not “Full access” (= simple user). The API returns
403 PERMISSION_DENIEDwith a user role.
| Search Console role | API result |
|---|---|
| Full user | โ HTTP 403 PERMISSION_DENIED |
| Owner | โ HTTP 200 |
๐ Credentials storage
The JSON file is stored outside the Grav directory, accessible only by www-data:
sudo mkdir -p /etc/grav-google-indexing
sudo chown www-data:www-data /etc/grav-google-indexing
sudo chmod 700 /etc/grav-google-indexing
sudo cp service-account.json /etc/grav-google-indexing/
sudo chown www-data:www-data /etc/grav-google-indexing/service-account.json
sudo chmod 600 /etc/grav-google-indexing/service-account.json๐ Hooks โ identical to the IndexNow plugin
public static function getSubscribedEvents(): array
{
return [
'onAdminAfterSave' => ['onAdminAfterSave', 0],
'onMcpAfterSave' => ['onMcpAfterSave', 0],
];
}โ๏ธ RS256 JWT in pure PHP โ no Composer
The Google Indexing API requires an OAuth2 access token. Since google/auth is not available in Grav, the JWT is generated manually.
โ ๏ธ Note the base64url encoding (not standard base64) โ
+becomes-,/becomes_, and=padding is removed.
private function base64url(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private function getGoogleAccessToken(): ?string
{
$credentials = json_decode(
file_get_contents('/etc/grav-google-indexing/service-account.json'),
true
);
$now = time();
$header = $this->base64url(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$payload = $this->base64url(json_encode([
'iss' => $credentials['client_email'],
'scope' => 'https://www.googleapis.com/auth/indexing',
'aud' => 'https://oauth2.googleapis.com/token',
'exp' => $now + 3600,
'iat' => $now,
]));
$toSign = $header . '.' . $payload;
openssl_sign($toSign, $signature, $credentials['private_key'], OPENSSL_ALGO_SHA256);
$jwt = $toSign . '.' . $this->base64url($signature);
// Exchange JWT for access token
$ch = curl_init('https://oauth2.googleapis.com/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwt,
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
return $response['access_token'] ?? null;
}๐ค Submitting to the API
The API accepts one URL at a time (no bulk like IndexNow). All 3 variants are submitted in a loop:
private function handleRoute(string $route): void
{
$host = 'arleo.eu';
$urls = [
"https://{$host}{$route}",
"https://{$host}/fr{$route}",
"https://{$host}/en{$route}",
];
$token = $this->getGoogleAccessToken();
if (!$token) {
$this->grav['log']->error('[GoogleIndexing] Unable to obtain access token.');
return;
}
foreach ($urls as $url) {
$ch = curl_init('https://indexing.googleapis.com/v3/urlNotifications:publish');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['url' => $url, 'type' => 'URL_UPDATED']),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"Authorization: Bearer {$token}",
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 200) {
$this->grav['log']->info("[GoogleIndexing] โ
OK {$url}");
} else {
$this->grav['log']->warning("[GoogleIndexing] โ ๏ธ HTTP {$httpCode} {$url} โ {$response}");
}
}
}โ Verification in logs
tail -f /var/www/grav/logs/grav.log | grep -E "IndexNow|GoogleIndexing"
# [GoogleIndexing] โ
OK https://arleo.eu/grav-plugin-indexnow
# [GoogleIndexing] โ
OK https://arleo.eu/fr/grav-plugin-indexnow
# [GoogleIndexing] โ
OK https://arleo.eu/en/grav-plugin-indexnow
# [IndexNow] โ
Submission OK (HTTP 200): https://arleo.eu/fr/grav-plugin-indexnow, ...๐ Complete SEO pipeline
Page save
(admin or MCP)
โ
โโโโโโโโโโโดโโโโโโโโโโ
โ โ
โผ โผ
IndexNow GoogleIndexing
(bulk POST) (JWT RS256)
โ โ
โผ โผ
Bing / Yandex Google| Engine | Plugin | Mechanism | Status |
|---|---|---|---|
| Bing / Yandex | IndexNow | Bulk POST api.indexnow.org | โ Active |
| GoogleIndexing | JWT RS256 + indexing.googleapis.com | โ Active |
๐ Conclusion
The SEO pipeline is now complete โ every page save instantly notifies Google, Bing and Yandex with no manual intervention. The RS256 JWT is generated in pure PHP with no Composer dependency, avoiding any risk of conflict with existing Grav dependencies.
To go further:
- ๐ก Cache the Google access token (valid for 1h) to avoid an OAuth call on every save and reduce latency
- ๐ก Add an initial bulk submission on plugin startup to index all existing pages in a single pass