LaraIndexNow

LaraIndexNow implements the IndexNow protocol in Laravel. You publish a key file on your domain, and from then on every Eloquent write that changes what is on a page submits that URL to Bing, Yandex, Seznam, Naver and Yep in one request. Google does not participate in IndexNow, so this sits next to your sitemap rather than replacing it.
Written against version 1.4.
This page is about running it: the key, several domains, the commands, the config and what to check when nothing goes out. For the shape of the API itself, conditions, transitions and what counts as a content change, read Instant search engine indexing in Laravel with LaraIndexNow.
Installation
composer require edulazaro/laraindexnow php artisan vendor:publish --tag=indexnow-config php artisan indexnow:key --file php artisan indexnow:verify
Registering a model is three lines in a service provider, and nothing goes on the model itself:
use EduLazaro\IndexNow\Facades\IndexNow; IndexNow::track(Post::class) ->url('blog.show') ->when('published');
The key file is the whole authentication
There is no account and no token. If you can publish text at https://yourdomain.com/{key}.txt, the domain is yours. indexnow:key --file generates the key, writes INDEXNOW_KEY to your .env and drops the file in public/.
Generate the file even though the package also serves that URL through a route. A real file on disk is handed over by nginx without booting the framework, so it keeps answering while php artisan down returns a 503 for every route, which is exactly when a deploy is most likely to have a crawler knocking.
Worth deciding on purpose: unlike most keys this one is public by design and has to match a file that lives in the repository. Keeping the value in the config file means the two cannot drift apart and a deploy needs nothing else. Keeping it in .env means a server that misses the variable serves a key file that no longer matches, and nothing tells you until a submission is rejected. indexnow:verify is what catches that, and it belongs in your post deploy checks.
If the file lives somewhere other than the root of the domain, point key_location at it. Set serve_key_file to false to drop the package route entirely.
Several domains from one application
Map a key per host and the package picks the right one for each URL:
'keys' => [ 'example.com' => 'ecd5c0c8f2ad4b0e9a0d1f2c3b4a5968', 'example.fr' => '7f1c9a2b3d4e5f60718293a4b5c6d7e8', ],
hosts is the allowlist: a URL whose host is not on it is dropped before it reaches the endpoint. Leave it empty and it is derived from the keys above, or from app.url when there is a single key. This is the setting that silently discards everything if your generated URLs come out as localhost while the key is registered for a real domain.
Declaring a model as a class
Chained conditions are fine until a registration grows. track() takes a policy class as a second argument, and only url() is required:
use EduLazaro\IndexNow\Policy; use Illuminate\Database\Eloquent\Model; class PostIndexNow extends Policy { public function url(Model $post): ?string { return route('blog.show', $post); } public function when(Model $post): bool { return $post->published && $post->approved; } public function ignoring(): array { return ['view_count', 'stats_cache']; } public function affects(Model $post): array { return [route('blog.index'), route('blog.category', $post->category)]; } }
IndexNow::track(Post::class, PostIndexNow::class);
Returning null from url() means this record has no URL and is never submitted.
Commands
indexnow:key |
Generate a key. --host, --length, --file |
indexnow:verify |
Check the key file is publicly readable. --host |
indexnow:models |
List the tracked models and what configures each one |
indexnow:submit |
Submit URLs by hand. --queue, --force |
indexnow:sync |
Walk a tracked model and submit what matches. --chunk, --batch, --limit, --queue, --force, --dry-run |
indexnow:flush |
Send whatever is sitting in the buffer. --queue |
indexnow:models is the fastest way to tell whether a registration is live at all: a provider that never ran shows nothing.
Configuration
enabled |
Kill switch, reads INDEXNOW_ENABLED. Turn submissions off during an incident without a deploy |
environments |
Where submissions may happen. production only, by default |
driver |
http talks to the endpoint, null runs the whole pipeline and drops the request at the last step |
queue |
Model events buffer and one delayed job sends the batch. Disabling it sends inline, which is rarely what you want outside tests |
buffer |
delay seconds before flushing and max_urls before flushing early. The protocol allows 10000 URLs per request |
dedupe |
The same URL is not sent twice within ttl seconds |
ignored_attributes |
Global list of attributes whose change is not a content change. Each model's timestamps are added automatically |
log |
null, log or database |
A staging machine that should exercise the whole path without talking to the endpoint is driver at null plus log at log.
Keep the deduplication window short. It absorbs bursts, it is not a record of what is already indexed: publish at 09:00 with a 24 hour window and the correction you make at 15:00 never goes out.
Knowing what went out
Set log to database and publish the migration:
php artisan vendor:publish --tag=indexnow-migrations php artisan migrate
Or listen, which is also how you react to a failure:
use EduLazaro\IndexNow\Events\SubmissionFailed; use EduLazaro\IndexNow\Events\UrlsSubmitted; Event::listen(SubmissionFailed::class, function (SubmissionFailed $event) { $event->result->host; // the host the batch was for $event->result->urls; // what was in it $event->result->status; // HTTP status, null when the request never happened $event->result->message; $event->exception; });
Retries live on the job. A 429 is released with the endpoint's own Retry-After and a 5xx backs off at 1, 5 and 15 minutes. A rejected key or a malformed URL is never retried, because retrying will not fix it.
When nothing is submitted
In the order worth checking:
- The environment.
environmentsisproductionby default, so a laptop submits nothing on purpose. INDEXNOW_ENABLED, and whether the config cache was cleared after it changed.- The key file.
indexnow:verify. An unreachable or stale key file is behind almost every rejection, and the endpoint says nothing useful about which half failed. - The host. URLs whose host is not allowed are dropped silently. If
route()is producinglocalhostwhile your key coversexample.com, nothing will ever go out. - Query builder writes.
Post::where(...)->update([...])instantiates no model, so no observer runs. That is Eloquent, not this package:indexnow:syncis the answer, and it evaluates the same conditions so there is no second copy of the rule. - The worker. Buffered URLs need the queue to drain them.
indexnow:flushsends what is waiting. - The deduplication window, which is doing its job when a second edit within the hour goes quiet.
For seeding and imports, where you want the writes but not the noise:
IndexNow::withoutSubmissions(function () { Post::factory()->count(50_000)->create(); });
built and maintained by Edu Lazaro · MIT license