danielpetrica / indexnow-submitter
IndexNow integration for Laravel — submit URL changes to search engines instantly
Package info
github.com/danielpetrica/indexnow-submitter
Type:laravel-package
pkg:composer/danielpetrica/indexnow-submitter
Requires
- php: ^8.3
- illuminate/database: ^13.0
- illuminate/http: ^13.0
- illuminate/support: ^13.0
Requires (Dev)
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Submit URL changes to search engines instantly via the IndexNow protocol.
Features
- Ad-hoc queued submissions -- URL changes are submitted via a dedicated queue, dispatched
after_commitso rolled-back transactions never reach search engines. - Scheduled bulk submissions -- A periodic sweep command resubmits only URLs whose content actually changed since the last submission.
- Smart deduplication -- Changed URLs are tracked in a durable table; unchanged URLs are never re-submitted, keeping you off search engines' deprioritisation radar.
- Config-driven model registration -- Declare which Eloquent models to watch and how to build their URLs, either via a fluent API or config array.
- Key management -- Generate, store, serve, and rotate IndexNow keys with correct protocol semantics (stable root placement, exact-host checks, proper headers).
- Multi-host support -- Each host gets its own key, route, and tracking scope.
- Environment guard -- Submissions only fire in configured environments (production, staging by default).
- Events --
UrlIndexedandUrlIndexFailedevents for custom integrations.
Requirements
- PHP 8.2+
- Laravel 11+ or 12+
Installation
composer require danielpetrica/indexnow-submitter php artisan vendor:publish --tag=indexnow-config php artisan migrate
Migrations are auto-loaded by the service provider. Publishing them (--tag=indexnow-migrations) is optional and only needed if you want to customise the schema.
Five-Minute Quick Start
# 1. Install the package composer require danielpetrica/indexnow-submitter # 2. Publish config php artisan vendor:publish --tag=indexnow-config # 3. Generate a key for your host php artisan indexnow:key example.com # 4. Add the key to your .env # INDEXNOW_KEY_EXAMPLE_COM=abc123def456... # 5. Run the migrations php artisan migrate # 6. Register one model (see Model Registration below) # 7. Verify your key is reachable php artisan indexnow:verify
Then register the scheduled command in your AppServiceProvider:
use Illuminate\Support\Facades\Schedule; public function boot(): void { Schedule::command('indexnow:sweep') ->everyFifteenMinutes() ->withoutOverlapping(); }
Configuration
After publishing, config/indexnow.php contains all settings with detailed comments. Key values:
// The host this application serves (bare hostname, no scheme) 'host' => env('INDEXNOW_HOST', parse_url(config('app.url'), PHP_URL_HOST)), // Your IndexNow API key 'key' => env('INDEXNOW_KEY'), // The global IndexNow endpoint (fans out to Bing, Yandex, Seznam, Naver, Yep, Amazon) 'endpoint' => env('INDEXNOW_ENDPOINT', 'https://api.indexnow.org/indexnow'), // Models to watch (config-array shortcut) 'watched_models' => [ // App\Models\Post::class => [ // 'route' => 'posts.show', // 'params' => ['post' => 'slug'], // ], ], // Queue settings 'queue' => env('INDEXNOW_QUEUE', 'indexnow'), 'chunk_size' => 500, // URLs per HTTP request (hard cap: 10,000) 'min_resubmit_interval' => 600, // seconds between re-submitting unchanged URLs // Only submit in these environments 'environments' => ['production', 'staging'],
Key file serving
The package serves the key file at /{key}.txt with correct headers (Content-Type: text/plain, X-Robots-Tag: noindex, Cache-Control: no-store). An optional static-file fallback (key_static_fallback) writes the key to public/ for resilience during php artisan down.
How It Works
Model created/updated/deleted
|
v
Observer derives URL from route mapping
|
v
Tracking table row created (firstOrCreate by host+url)
|
v
Queued job submitted (after_commit, coalesced window)
|
v
IndexNow client POSTs batch to api.indexnow.org
|
v
Search engines notified (Bing, Yandex, Seznam, Naver, Yep, Amazon)
The scheduled sweep (indexnow:sweep) runs periodically and catches anything observers miss (query-builder writes, raw DB updates) by comparing each URL's model_updated_at against last_submitted_at.
Google does not participate in IndexNow. This package cannot affect Google indexing.
Model Registration
Fluent API (recommended)
Register models in your AppServiceProvider::boot() or a dedicated service provider:
use App\Models\Post; use Illuminate\Support\Facades\IndexNow; IndexNow::register(Post::class) ->url(fn ($post) => route('posts.show', $post->slug)) ->when(fn ($post) => $post->isPublic()); // optional visibility gate
register(Model::class)-- declares a model to watch.url(callable)-- sets the URL resolver. Must return an absolutehttpsURL ornullto skip.when(callable)-- optional visibility gate. Whenfalse, the URL is never tracked.
Config array (simple cases)
For straightforward route + attribute mappings:
// config/indexnow.php 'watched_models' => [ App\Models\Post::class => [ 'route' => 'posts.show', 'params' => ['post' => 'slug'], ], ],
Closures are forbidden in config (they break config:cache). For complex URL logic, implement IndexNow\Contracts\UrlGenerator and reference the class name in the url key.
Trait-based registration
You can also use the SubmitsToIndexNow trait directly on your model:
use IndexNow\Submitter\Concerns\SubmitsToIndexNow; class Post extends Model { use SubmitsToIndexNow; public function shouldIndex(): bool { return $this->isPublished(); } }
Commands
indexnow:key -- Generate or rotate an API key
# Generate a key for the default host php artisan indexnow:key # Generate a key for a specific host php artisan indexnow:key example.com # Write the key as a static file (survives artisan down) php artisan indexnow:key example.com --store=file # Store the key in the database (for multi-server deployments) php artisan indexnow:key example.com --store=db # Rotate an existing key php artisan indexnow:key example.com --rotate
The command outputs the key file URL and verifies it is reachable.
indexnow:send -- Send all pending URLs now
# Send everything pending to the queue php artisan indexnow:send # Send synchronously (useful for debugging) php artisan indexnow:send --sync # Filter by host php artisan indexnow:send --host=example.com # Override chunk size php artisan indexnow:send --chunk=1000
indexnow:sweep -- Detect changes and submit
# Run a single sweep pass (for testing) php artisan indexnow:sweep --once # Normal mode (registered with the scheduler automatically) php artisan indexnow:sweep
indexnow:verify -- Check key reachability
# Verify the default host php artisan indexnow:verify # Verify a specific host php artisan indexnow:verify example.com
Checks: key file accessibility, HTTP reachability, body match, response headers (Cache-Control, X-Robots-Tag). Exits non-zero on failure (CI-friendly).
Queue Setup
The package uses a dedicated queue name (indexnow by default). Set up a worker for it:
php artisan queue:work --queue=indexnow
Supervisor configuration
[program:indexnow-worker] process_name=%(program_name)s_%(process_num)02d command=php /path/to/your/app/artisan queue:work --queue=indexnow --sleep=3 --tries=5 autostart=true autorestart=true numprocs=2 redirect_stderr=true stdout_logfile=/path/to/your/app/storage/logs/indexnow-worker.log
Events
The package dispatches two events you can listen to:
UrlIndexed
Fired after a URL is successfully submitted (HTTP 200 or 202).
use IndexNow\Submitter\Events\UrlIndexed; class UrlIndexedListener { public function handle(UrlIndexed $event): void { // $event->record -- IndexNowUrl model // $event->status -- HTTP status code (200 or 202) // $event->url -- The submitted URL } }
UrlIndexFailed
Fired when a URL submission fails permanently (4xx status).
use IndexNow\Submitter\Events\UrlIndexFailed; class UrlIndexFailedListener { public function handle(UrlIndexFailed $event): void { // $event->record -- IndexNowUrl model // $event->status -- HTTP status code (400, 403, 422) // $event->url -- The failed URL // $event->reason -- Human-readable failure reason } }
Register listeners in your EventServiceProvider:
protected $listen = [ \IndexNow\Submitter\Events\UrlIndexed::class => [ \App\Listeners\UrlIndexedListener::class, ], \IndexNow\Submitter\Events\UrlIndexFailed::class => [ \App\Listeners\UrlIndexFailedListener::class, ], ];
Customization
Custom route name for the key file
// config/indexnow.php 'key_route_name' => 'my-app.indexnow.key',
Custom URL generator
Implement IndexNow\Contracts\UrlGenerator for complex URL logic:
namespace App\IndexNow; use Illuminate\Database\Eloquent\Model; use IndexNow\Submitter\Contracts\UrlGenerator; class PostUrlGenerator implements UrlGenerator { public function generate(Model $model): ?string { return route('posts.show', ['slug' => $model->slug, 'locale' => $model->locale]); } }
Reference it in config:
'watched_models' => [ App\Models\Post::class => [ 'url' => \App\IndexNow\PostUrlGenerator::class, ], ],
Custom visibility gate
Implement IndexNow\Contracts\Visibility:
namespace App\IndexNow; use Illuminate\Database\Eloquent\Model; use IndexNow\Submitter\Contracts\Visibility; class PostVisibility implements Visibility { public function shouldIndex(Model $model): bool { return $model->isPublished() && !$model->isPrivate(); } }
Service provider bindings
The following singletons are available for replacement:
IndexNow\Submitter\Services\Client-- the HTTP clientIndexNow\Submitter\Services\KeyManager-- key storage and retrievalIndexNow\Submitter\Services\UrlResolver-- URL derivation from models
Troubleshooting
403 Forbidden (Invalid Key)
The key file is missing or unreachable on your host. Run:
php artisan indexnow:verify
Common causes:
- Key file not served at
/{key}.txt(check route registration) robots.txtblocking the key file URL- CDN caching a stale 404 -- ensure
Cache-Control: no-storeis on the key route - Key in
.envdoesn't match the generated key
202 Accepted (but URL not indexed)
A 202 means the request was received but key validation is pending. It is not a guarantee of indexing. Engines validate the key asynchronously. Run indexnow:verify to confirm the key is reachable, and wait for the next verification cycle.
Engine-specific note: Bing and Yandex return 202 for any syntactically valid key, even if validation later fails. Naver, Seznam, and others validate synchronously and return 403 on failure.
Model not tracked
- Verify the model is listed in
config('indexnow.watched_models')or uses theSubmitsToIndexNowtrait. - Ensure the model has
updated_attimestamps enabled. - Check that the URL resolver returns a valid
httpsURL (notnull).
CDN serving stale content
Purge your CDN cache before submitting URLs to IndexNow, not after. The package's key route sends Cache-Control: no-store to prevent CDN caching of the key file, but your application pages are your responsibility.
Observer-invisible writes
Model::where()->update() and raw query-builder writes don't trigger Eloquent observers. The scheduled indexnow:sweep command catches these on its next pass by comparing model_updated_at against last_submitted_at.
config:cache breaks the model map
Closures are forbidden in config/indexnow.php. Use the fluent register() API or reference callable class names (implementing UrlGenerator/Visibility contracts) instead.
Route name collision
If your app already defines a route named indexnow.key, change it:
'key_route_name' => 'my-app.indexnow.key',
Testing
# Run the test suite ./vendor/bin/phpunit # Run with coverage ./vendor/bin/phpunit --coverage-html=coverage
License
The MIT License (MIT). Please see LICENSE for more information.