Contents

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:

๐Ÿง  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:

  1. Create a Google Cloud project
  2. Enable the Indexing API in the project
  3. Create a Service Account and download the JSON credentials file
  4. 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_DENIED with a user role.

Search Console roleAPI 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
EnginePluginMechanismStatus
Bing / YandexIndexNowBulk POST api.indexnow.orgโœ… Active
GoogleGoogleIndexingJWT 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