dancing-janissary / laravel-seo-indexing
Automatically notify Google Indexing API and IndexNow (Bing, Yandex) on Eloquent model CRUD operations.
Package info
github.com/dancing-janissary/laravel-seo-indexing
pkg:composer/dancing-janissary/laravel-seo-indexing
Requires
- php: ^8.2
- google/apiclient: ^2.15
- illuminate/database: ^11.0 || ^12.0
- illuminate/http: ^11.0 || ^12.0
- illuminate/queue: ^11.0 || ^12.0
- illuminate/support: ^11.0 || ^12.0
Requires (Dev)
- orchestra/testbench: ^9.0 || ^10.0
- phpunit/phpunit: ^10.5 || ^11.0
- dev-main
- v1.2.1
- v1.2.0
- v1.1.1
- v1.1.0
- v1.0.0
- dev-dev
- dev-feature/add-unit-tests
- dev-feature/enable-multilanguage-support
- dev-feature/phase-8-packagist-prep
- dev-feature/phase-6-facade-trait
- dev-feature/phase-5-manager-jobs
- dev-feature/phase-4-models-logging
- dev-feature/phase-3-clients
- dev-feature/phase-2-config-provider
This package is auto-updated.
Last update: 2026-04-17 12:17:48 UTC
README
Automatically notify Google Indexing API and IndexNow (Bing, Yandex, Seznam, Naver) whenever your Eloquent models are created, updated, or deleted. Attach a single trait to any model and your pages are indexed without a single extra line of code.
Table of Contents
- Features
- Requirements
- Installation
- Configuration
- Usage
- Queue Setup
- Logging & Querying Submission History
- Architecture & Design Decisions
- API Quotas & Limits
- Testing
- Changelog
- License
Features
- ✅ Dual-engine — submits to both Google Indexing API v3 and IndexNow in one operation
- ✅ Zero-config CRUD hooks — attach
Indexabletrait and forget about it - ✅ Queue-first — all submissions dispatched as background jobs with automatic retry
- ✅ Sync fallback — disable queues entirely for simple setups or local dev
- ✅ Per-model control —
shouldIndex(),getIndexableUrl(), andwithoutIndexing()give fine-grained control - ✅ SoftDeletes aware — handles
deleted,restoredevents automatically - ✅ Full submission log — every API call recorded to DB with engine, status, and response payload
- ✅ Auto-pruning — configurable log retention via Laravel's built-in model pruning
- ✅ Deduplication — skips re-submission if the same URL was successfully submitted recently
- ✅ Multi-engine IndexNow — pings Bing, Yandex, and others in a single batch request
- ✅ Multi-language routes — submit all locale-specific URLs (e.g.
/en/page,/fr/page) in one batch when a model changes
Requirements
| Requirement | Version |
|---|---|
| PHP | ^8.2 |
| Laravel | ^11.0 |
| Google Service Account | Required for Google Indexing API |
| IndexNow API Key | Required for IndexNow (Bing, Yandex, etc.) |
Installation
Install via Composer:
composer require dancing-janissary/laravel-seo-indexing
Laravel's auto-discovery will register the service provider and SeoIndexing facade automatically.
Publish the config file and migrations:
# Publish everything at once php artisan vendor:publish --tag=seo-indexing # Or selectively php artisan vendor:publish --tag=seo-indexing-config php artisan vendor:publish --tag=seo-indexing-migrations
Run the migrations:
php artisan migrate
Configuration
After publishing, the config file is located at config/seo-indexing.php.
Google Indexing API Setup
The Google Indexing API requires a Service Account with domain-wide delegation. Follow these steps:
- Go to the Google Cloud Console and create a project
- Enable the Indexing API for your project
- Create a Service Account and download the JSON credentials file
- In Google Search Console, add the service account email as an Owner of your property
- Store the JSON key file somewhere safe on your server — never inside your project root or git repo
# Example: store outside the web root
/etc/google/my-site-indexing-credentials.json
Set the path in your .env:
GOOGLE_INDEXING_CREDENTIALS_PATH=/etc/google/my-site-indexing-credentials.json
⚠️ Security: The credentials JSON file contains a private key. Never commit it to version control. Add
*-service-account.jsonand*credentials*.jsonto your.gitignore.
IndexNow Setup
IndexNow uses a simple API key for authentication. The key must be served as a text file at your domain root so search engines can verify ownership.
- Generate a key — must be alphanumeric, minimum 8 characters:
# Generate a random key
openssl rand -hex 16
- Create a verification file at your domain root:
https://example.com/{your-key}.txt
The file must contain only the key itself as plain text.
- Set your key in
.env:
INDEXNOW_KEY=your_key_here
Tip: You only need to verify with one IndexNow engine — all others accept the same key once verified. The package submits to Bing, Yandex, and
api.indexnow.orgby default.
Environment Variables
Add these to your .env file:
# Google Indexing API GOOGLE_INDEXING_CREDENTIALS_PATH=/absolute/path/to/credentials.json # IndexNow INDEXNOW_KEY=your_indexnow_key INDEXNOW_KEY_FILE=your_indexnow_key.txt # optional, defaults to {key}.txt # Queue (recommended for production) SEO_INDEXING_QUEUE_ENABLED=true SEO_INDEXING_QUEUE_CONNECTION=redis # or database, sqs, etc. SEO_INDEXING_QUEUE_NAME=indexing # dedicated queue name # Log retention SEO_INDEXING_LOG_RETENTION=30 # days, 0 = keep forever
Full Config Reference
// config/seo-indexing.php return [ // Enable or disable engines globally 'engines' => [ 'google' => true, 'indexnow' => true, ], 'google' => [ 'credentials_path' => env('GOOGLE_INDEXING_CREDENTIALS_PATH'), 'scopes' => ['https://www.googleapis.com/auth/indexing'], ], 'indexnow' => [ 'key' => env('INDEXNOW_KEY'), 'key_file' => env('INDEXNOW_KEY_FILE', null), 'host' => env('APP_URL'), 'engines' => [ 'https://api.indexnow.org/indexnow', 'https://www.bing.com/indexnow', 'https://yandex.com/indexnow', ], ], 'queue' => [ 'enabled' => env('SEO_INDEXING_QUEUE_ENABLED', true), 'connection' => env('SEO_INDEXING_QUEUE_CONNECTION', 'default'), 'name' => env('SEO_INDEXING_QUEUE_NAME', 'indexing'), 'retry_after' => 90, ], 'logging' => [ 'enabled' => true, 'retention_days' => env('SEO_INDEXING_LOG_RETENTION', 30), ], 'http' => [ 'timeout' => 30, 'connect_timeout' => 10, 'retry' => [ 'times' => 3, 'sleep' => 1000, ], ], ];
Usage
The Indexable Trait
Add the Indexable trait to any Eloquent model whose URLs should be submitted to search engines:
use DancingJanissary\SeoIndexing\Traits\Indexable; class Page extends Model { use Indexable; }
That's it. The following events are now wired automatically:
| Eloquent Event | Action Sent |
|---|---|
created / updated |
URL_UPDATED |
deleted |
URL_DELETED |
restored (SoftDeletes) |
URL_UPDATED |
By default the URL is built from the model's slug attribute (or its primary key as a fallback). Override getIndexableUrl() to return the correct public URL for your model:
class Page extends Model { use Indexable; public function getIndexableUrl(): string { return route('pages.show', $this->slug); } }
Or set a URL prefix to use the default slug-based URL generation:
protected function getIndexablePrefix(): string { return '/blog'; // Produces: https://example.com/blog/{slug} }
Controlling Which Pages Get Indexed
Override shouldIndex() to add conditions. Only return true when the page should actually be visible to search engines:
class Page extends Model { use Indexable; public function shouldIndex(): bool { return parent::shouldIndex() && $this->status === 'published' && ! $this->is_private; } }
When shouldIndex() returns false, no job is dispatched and no log entry is written.
Manual Submission via Facade
Use the SeoIndexing facade to submit URLs outside of model events — useful in controllers, commands, or observers:
use DancingJanissary\SeoIndexing\Facades\SeoIndexing; // Submit a URL as updated SeoIndexing::submit('https://example.com/page'); // Submit a URL as deleted SeoIndexing::delete('https://example.com/old-page');
You can also trigger indexing directly on a model instance:
use DancingJanissary\SeoIndexing\SeoIndexingManager; // Submit as updated $page->index(); // Submit as deleted $page->index(SeoIndexingManager::ACTION_DELETED);
Batch Submission
Submit multiple URLs in one call. IndexNow supports native batch requests (up to 10,000 URLs); Google sends individual requests per URL internally.
SeoIndexing::submitBatch([ 'https://example.com/page-one', 'https://example.com/page-two', 'https://example.com/page-three', ]); SeoIndexing::deleteBatch([ 'https://example.com/removed-one', 'https://example.com/removed-two', ]);
Multi-Language Routes
If your application serves content in multiple languages with locale-prefixed URLs (e.g. /en/page, /fr/page, /de/page), override getIndexableUrls() to return all locale variants. When a model is created, updated, or deleted, all URLs are submitted as a batch:
class Page extends Model { use Indexable; public function getIndexableUrls(): ?array { return collect(['en', 'fr', 'de'])->mapWithKeys(fn ($locale) => [ $locale => route('pages.show', ['locale' => $locale, 'slug' => $this->slug]), ])->all(); } }
This produces:
https://example.com/en/my-page
https://example.com/fr/my-page
https://example.com/de/my-page
All three URLs are submitted together via submitBatch() whenever the model fires a saved, deleted, or restored event.
How it works:
- Return
null(default) to use the single-URLgetIndexableUrl()behavior — fully backward compatible - Return an associative array keyed by locale (keys are for your convenience; only the URL values are submitted)
- Return an empty array to fall back to single-URL mode
- The
index()method also respectsgetIndexableUrls()for manual submissions
Note: Neither Google Indexing API nor IndexNow accept hreflang metadata. They only receive URLs. For search engines to understand locale relationships, ensure your HTML includes proper
<link rel="alternate" hreflang="...">tags. The Indexing API tells Google "crawl this URL now" — Google discovers hreflang annotations when it crawls the page.
Disabling Indexing for Bulk Operations
When importing or seeding large numbers of records, disable indexing to avoid exhausting API quotas:
// Option A — static disable/enable Page::disableIndexing(); foreach ($importData as $row) { Page::create($row); } Page::enableIndexing();
// Option B — closure (re-enables automatically, even if an exception is thrown) $page->withoutIndexing(function () use ($page) { $page->update(['status' => 'draft']); });
Queue Setup
Queue-based submissions are strongly recommended for production. Without a queue, every model save blocks the request while waiting for Google's API response (typically 1–3 seconds).
Why queues?
| Sync | Queue | |
|---|---|---|
| Request speed | Slows down (API latency) | Instant return |
| Failure handling | Lost on timeout | Auto-retry with backoff |
| Bulk imports | Blocks until all submitted | Non-blocking |
| Visibility | None | failed_jobs table |
Dedicated queue worker
Run a dedicated worker for the indexing queue to keep SEO submissions isolated from your main application jobs:
php artisan queue:work redis --queue=indexing --tries=2 --timeout=60
For production with Supervisor, add a separate program block:
[program:seo-indexing-worker] command=php /var/www/html/artisan queue:work redis --queue=indexing --tries=2 --timeout=60 autostart=true autorestart=true numprocs=1
Log retention (auto-pruning)
Add model:prune to your scheduler to automatically clean up old log entries based on the logging.retention_days config value:
// routes/console.php Schedule::command('model:prune')->daily();
Logging & Querying Submission History
Every API submission — whether successful or failed — is recorded in the seo_indexing_logs table. Use the SeoIndexingLog model to query the history:
use DancingJanissary\SeoIndexing\Models\SeoIndexingLog; // All failed submissions in the last 7 days SeoIndexingLog::failed()->recent(7)->get(); // All Google failures SeoIndexingLog::failed()->forEngine('google')->latest()->get(); // Full history for a specific URL SeoIndexingLog::forUrl('https://example.com/page')->latest()->get(); // All URL_DELETED submissions SeoIndexingLog::forAction('URL_DELETED')->get(); // Successful Bing submissions SeoIndexingLog::successful()->forEngine('indexnow:www.bing.com')->get();
Log table columns
| Column | Description |
|---|---|
url |
The submitted URL |
action |
URL_UPDATED or URL_DELETED |
engine |
google, indexnow:www.bing.com, etc. |
success |
Boolean result |
http_status |
HTTP response code from the engine |
message |
Error message on failure |
payload |
Raw JSON response from the API |
indexable_type |
Model class that triggered the submission |
indexable_id |
Model primary key |
job_id |
UUID linking the log entry to its queue job |
queued |
Whether this was dispatched via a job |
Architecture & Design Decisions
Dual-client architecture
Each engine (GoogleIndexingClient, IndexNowClient) implements the same IndexingClientContract interface. They are bound independently in the service container, which means:
- They can be mocked independently in tests
- A failure or misconfiguration in one engine does not affect the other
- New engines can be added by implementing the contract and registering in the service provider
One job per engine
SubmitUrlJob accepts an $engine parameter and is dispatched separately for each enabled engine. This isolation means a Google quota error doesn't prevent Bing from receiving the submission, and each engine has its own entry in failed_jobs for independent retry tracking.
Google OAuth2 token handling
The package uses google/auth (Google's official PHP auth library) rather than the heavier google/apiclient. ServiceAccountCredentials reads the JSON key file, signs a JWT, exchanges it for a Bearer token, and caches it for its 1-hour lifetime — all internally. This keeps the dependency footprint small while handling the full OAuth2 service account flow correctly.
IndexNow native batching
Unlike Google (which requires one HTTP request per URL), IndexNow supports up to 10,000 URLs in a single POST. The IndexNowClient::submitBatch() method takes full advantage of this — a batch of 500 URLs becomes 3 HTTP requests (one per engine endpoint) instead of 1,500.
Retry strategy
The HTTP client retries on 5xx and connection errors but not on 4xx errors. A 403 Forbidden from Google means the credentials are wrong — retrying with the same credentials will always fail and wastes quota. The job layer adds a second retry tier at a higher level for transient failures that survive HTTP retries.
Deduplication guard
Before dispatching any job, the manager checks whether the same URL was successfully submitted to the same engine within the last 60 minutes. This prevents quota exhaustion during rapid successive saves (e.g. autosave, touch, or event chains on the same model).
Type+ID serialization
The job stores indexable_type and indexable_id rather than the Eloquent model instance. Serializing a full model (with its relations) into a queue payload creates large payloads and risks stale data by the time the job runs. Storing the class and key keeps the payload minimal and always fetches a fresh model on execution.
API Quotas & Limits
Be aware of the following limits when planning your usage:
Google Indexing API
| Limit | Value |
|---|---|
| Requests per day | 200 per service account |
| Requests per minute | 600 |
| Supported URL types | Job posting and livestream pages only (officially) |
Note: Google officially supports the Indexing API only for job posting and livestream structured data pages. Many developers use it for general pages successfully, but this is not officially guaranteed.
IndexNow
| Limit | Value |
|---|---|
| URLs per batch | Up to 10,000 |
| Daily limit | No hard limit published (10,000+ documented) |
| Engines notified | All IndexNow-compatible engines share submissions |
Testing
The package uses PHPUnit with Orchestra Testbench.
Run the test suite:
composer test # or directly vendor/bin/phpunit
Mocking in your application
Both clients are bound as singletons and can be swapped in tests:
use DancingJanissary\SeoIndexing\Clients\GoogleIndexingClient; use DancingJanissary\SeoIndexing\Data\IndexingResult; // Mock the Google client in a feature test $this->mock(GoogleIndexingClient::class) ->shouldReceive('submit') ->once() ->with('https://example.com/page', 'URL_UPDATED') ->andReturn(IndexingResult::success( engine: 'google', url: 'https://example.com/page', action: 'URL_UPDATED', httpStatus: 200, )); // Now trigger the model event Page::factory()->create(['slug' => 'page', 'status' => 'published']);
Disabling indexing in tests
Add this to your TestCase base class to disable all API submissions during the test suite:
protected function setUp(): void { parent::setUp(); // Disable all indexing submissions in tests config(['seo-indexing.engines' => ['google' => false, 'indexnow' => false]]); }
Changelog
See CHANGELOG.md for release history.
License
The MIT License (MIT). See LICENSE for details.