siberfx / google-indexing
Notify Google (Indexing API + Search Console sitemaps) and IndexNow search engines (Bing, Yandex, Naver, Seznam, Yep) about new, updated and deleted URLs. Reads your sitemap.xml, auto-hooks into Eloquent models, queues everything.
Requires
- php: ^8.3
- ext-json: *
- ext-libxml: *
- ext-openssl: *
- ext-simplexml: *
- illuminate/bus: ^12.0|^13.0
- illuminate/cache: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/queue: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.9
- laravel/pint: ^1.20
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Tell search engines about your pages the moment they change.
- Google — Indexing API (
URL_UPDATED/URL_DELETED) and sitemap submission through the Search Console API. - Bing, Yandex, Naver, Seznam, Yep … — one call via IndexNow.
- Reads your
sitemap.xml(sitemap indexes,.xml.gz,<lastmod>filtering) and submits every URL. - Auto-injecting — add one trait to an Eloquent model and it notifies engines on create / update / restore / delete. The IndexNow key file route and the scheduler entry register themselves.
- Queued, de-duplicated and retried. No Google SDK needed — OAuth is done with
openssl+ Laravel's HTTP client.
Requires PHP 8.3+ (8.3, 8.4 and 8.5 are tested) and Laravel 12 / 13.
Heads-up about "pinging". Google (2023) and Bing retired their
…/ping?sitemap=URLs, so pinging them does nothing. This package does what works today: it submits the sitemap file via the Search Console API, and the individual URLs via the Indexing API and IndexNow. Google officially supports the Indexing API forJobPostingandBroadcastEventpages; other page types are accepted by the API but Google gives no ranking guarantee.
Installation
composer require siberfx/google-indexing php artisan indexing:install
indexing:install generates an IndexNow key, appends the env keys to .env, and checks your Google credentials. Everything is auto-discovered — there is no provider to register. Publish the config only if you want to change it:
php artisan indexing:install --publish-config
Environment
GOOGLE_INDEXING_ENABLED=true # defaults to true only in production GOOGLE_INDEXING_CREDENTIALS=storage/app/google-service-account.json # path, or the raw JSON INDEXNOW_KEY=0f1e2d3c4b5a69788796a5b4c3d2e1f0 # generated by indexing:install GOOGLE_INDEXING_ENGINES=google,indexnow # drop one to disable it GOOGLE_INDEXING_SCHEDULE=true # auto-run indexing:sitemap daily GOOGLE_INDEXING_HISTORY=false # store every submission (needs the migration) BING_WEBMASTER_API_KEY= # optional, only if you add `bing` to GOOGLE_INDEXING_ENGINES
Run php artisan indexing:doctor --live after setup. It checks credentials, Search Console ownership, the IndexNow key file and the sitemap.
Where to get the credentials
| Service | What you need | Where to get it | Env variable |
|---|---|---|---|
| Google Indexing API | Service-account JSON key | Google Cloud Console | GOOGLE_INDEXING_CREDENTIALS |
| Google sitemap submission | Same service account + Search Console ownership | Search Console | GOOGLE_INDEXING_SITE_URL |
| IndexNow (Bing, Yandex, Naver, Seznam, Yep) | A self-generated key — no account | php artisan indexing:install |
INDEXNOW_KEY |
Google (Indexing API + Search Console) — step by step
- Create or pick a project at https://console.cloud.google.com/projectcreate.
- Enable the APIs (APIs & Services → Library):
- Web Search Indexing API — for URL notifications
- Google Search Console API — for sitemap submission
- Create a service account: IAM & Admin → Service Accounts → Create service account. No project roles are needed.
- Create the key: open the service account → Keys → Add key → Create new key → JSON. A file such as
my-project-1234abcd.jsondownloads. This is your credentials file. - Store it outside the web root, e.g.
storage/app/google-service-account.json(add it to.gitignore), and set:GOOGLE_INDEXING_CREDENTIALS=storage/app/google-service-account.json
You may paste the raw JSON into the variable instead (useful on platforms without file storage). - Grant access in Search Console — this step is required, otherwise every call returns
403 Permission denied:- Open https://search.google.com/search-console → select your property → Settings → Users and permissions → Add user.
- Paste the service account's e-mail (the
client_emailvalue in the JSON,…@…iam.gserviceaccount.com;php artisan indexing:installprints it) and set permission to Owner.
- Domain property? If you verified
sc-domain:example.comrather than a URL-prefix property, set:GOOGLE_INDEXING_SITE_URL=sc-domain:example.com
Otherwise it defaults to yourAPP_URL.
Quota: the Indexing API allows 200 publish requests/day by default; request more under IAM & Admin → Quotas.
IndexNow (Bing, Yandex, Naver, Seznam, Yep)
No signup. IndexNow proves you own the site with a key file:
- Run
php artisan indexing:install— it writesINDEXNOW_KEY=<random 32 chars>to.env. (You can also use any 8–128 charactera-z A-Z 0-9 -string, or generate one at https://www.bing.com/indexnow/getstarted.) - Deploy. The package serves the key at
https://your.site/{INDEXNOW_KEY}.txtautomatically — no file to upload. - Check it opens in a browser and shows the key.
Submitting to the shared endpoint (api.indexnow.org) notifies all participating engines. Optionally, view your submissions in Bing Webmaster Tools.
Usage
Facade
use Siberfx\GoogleIndexing\Facades\GoogleIndexing; // Created or updated GoogleIndexing::publish('https://example.com/blog/hello-world'); // Several at once, only to IndexNow GoogleIndexing::publish($urls, only: ['indexnow']); // Removed GoogleIndexing::delete('https://example.com/blog/old-post'); // Don't block the request — send via the queue GoogleIndexing::queue(route('posts.show', $post)); // Inspect what happened foreach (GoogleIndexing::publish($url) as $result) { $result->engine; // "google" | "indexnow" $result->accepted; // ['https://…'] $result->failed; // ['https://…' => 'Permission denied'] $result->successful(); } // When did Google last hear about this URL? (Indexing API — only URLs submitted through it) GoogleIndexing::status('https://example.com/blog/hello-world'); // Is it actually indexed? (Search Console URL Inspection — works for any URL) $check = GoogleIndexing::inspect('https://example.com/blog/hello-world'); $check['indexed']; // true | false $check['coverage']; // e.g. "Submitted and indexed"
Engine results also expose what to do next: $result->retryable lists URLs that failed temporarily (429, 5xx, network), and $result->deferred lists URLs held back because the daily Google quota is used up. Neither is counted as a permanent failure.
Sitemap
// Uses config('google-indexing.sitemap.url') or {APP_URL}/sitemap.xml GoogleIndexing::submitSitemap(); // Only pages modified in the last 24 hours, via the queue GoogleIndexing::submitSitemap(since: '1 day', queue: true); // A different sitemap, or a local file GoogleIndexing::submitSitemap('https://example.com/news-sitemap.xml'); // Just read the URLs GoogleIndexing::sitemapUrls(since: now()->subWeek());
submitSitemap() also registers the sitemap file itself with Google Search Console (when credentials are present). URLs with no <lastmod> are always included.
Eloquent models (auto-injecting)
use Illuminate\Database\Eloquent\Model; use Siberfx\GoogleIndexing\Concerns\Indexable; class Post extends Model { use Indexable; public function indexableUrl(): ?string { return route('posts.show', $this); } // Optional: drafts are not announced public function shouldBeIndexed(): bool { return $this->published_at?->isPast() ?? false; } }
That's it. created and updated send URL_UPDATED, restored too, deleted (including soft deletes) sends URL_DELETED. Writes that only touch updated_at are ignored (override ignoredIndexingAttributes() to change that). Jobs are queued after the DB transaction commits and identical bursts collapse into one job for 60 seconds.
Manual trigger: $post->requestIndexing(); or $post->requestIndexing(UrlAction::Deleted);
Artisan
php artisan indexing:sitemap # read sitemap, notify everyone php artisan indexing:sitemap --dry-run # list URLs, send nothing php artisan indexing:sitemap --since="1 day" --queue php artisan indexing:sitemap --url=https://example.com/news.xml --only=indexnow php artisan indexing:publish https://example.com/a https://example.com/b php artisan indexing:publish https://example.com/gone --delete php artisan indexing:status https://example.com/a php artisan indexing:inspect https://example.com/a # is it indexed? (--json for raw output) php artisan indexing:report --hours=24 # submissions per engine + latest failures (needs history) php artisan indexing:doctor [--live] # check the whole setup
Scheduling
Set GOOGLE_INDEXING_SCHEDULE=true and the package schedules indexing:sitemap itself (default dailyAt:03:00, configurable in sitemap.schedule.frequency, e.g. hourly, daily, dailyAt:05:30).
Two settings keep the daily run inside Google's 200 requests/day quota:
sitemap.dedupe(on by default) skips URLs already submitted that have not changed since. A URL is sent again aftersitemap.resubmit_after(30 days), or when its<lastmod>is newer than the last submission.GOOGLE_INDEXING_ONLY_CHANGED_WITHIN="1 day"sends only pages modified recently.
URLs that do not fit in today's Google quota are deferred and sent on a later run. The count is kept across runs and reset at midnight Pacific time.
Failed submissions are retried automatically, but only the URLs that failed temporarily (429, 5xx, network). Permanent rejections such as 403 Permission denied are not retried.
Events
Every engine submission dispatches Siberfx\GoogleIndexing\Events\UrlsSubmitted ($result, $action) — log it, alert on failures, store it:
Event::listen(UrlsSubmitted::class, function ($e) { if ($e->result->failed) { Log::warning('Indexing failed', $e->result->failed); } });
History (optional)
GOOGLE_INDEXING_HISTORY=true php artisan vendor:publish --tag=google-indexing-migrations php artisan migrate
Every engine result is then stored in google_indexing_submissions (engine, action, url, status accepted|failed|deferred|skipped, error). Use indexing:report or query the table directly.
Custom engines
use Siberfx\GoogleIndexing\Contracts\Engine; final class MyEngine implements Engine { /* name(), isConfigured(), submit() */ } // config/google-indexing.php 'engines' => ['google' => …, 'indexnow' => …, 'mine' => MyEngine::class], 'use' => ['google', 'indexnow', 'mine'],
Testing your app
Everything goes through Laravel's HTTP client and bus, so use the fakes:
Http::fake(['indexing.googleapis.com/*' => Http::response([]), 'oauth2.googleapis.com/*' => Http::response(['access_token' => 'x']), 'api.indexnow.org/*' => Http::response('')]); Bus::fake(); $post->update(['title' => 'New']); Bus::assertDispatched(SubmitUrlsJob::class, fn ($job) => $job->urls === [route('posts.show', $post)]);
Set GOOGLE_INDEXING_ENABLED=false in .env.testing to make the package a no-op.
Configuration reference
See config/google-indexing.php — every option is commented.
Testing this package
composer install && vendor/bin/pest
License
MIT