slimad / indexnow
Framework-agnostic IndexNow client and queue for submitting URL changes to participating search engines (Bing, Yandex, Seznam, Naver, Yep).
Requires
- php: ^8.2
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- infection/infection: ^0.29 || ^0.35
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.5 || ^12.0 || ^13.0
Suggests
- guzzlehttp/guzzle: A PSR-18 HTTP client implementation (^7.5)
- nyholm/psr7: A lightweight PSR-7/PSR-17 implementation (^1.8)
- slimad/indexnow-laravel: Laravel service provider, config, queue jobs and Artisan commands for this package
- symfony/http-client: An alternative PSR-18 HTTP client implementation (^6.4 || ^7.0)
This package is auto-updated.
Last update: 2026-08-31 17:46:36 UTC
README
A small, framework-agnostic IndexNow client and queue for PHP 8.2+. Notify Bing, Yandex, Seznam, Naver and Yep about new or updated URLs without pulling a single framework dependency.
The package is intentionally minimal: it exposes a queue, an HTTP-based submitter, a job that
drains the queue, and a handful of value objects. Framework integrations live in separate
packages — for Laravel use
slimad/indexnow-laravel.
Why?
Search engines other than Google still rely on classical crawling. IndexNow lets us push URL-changed notifications directly to participating engines so freshly added products or updated category pages are discovered quickly.
Installation
composer require slimad/indexnow
You also need any PSR-18 HTTP client and PSR-17 factories. For example:
composer require guzzlehttp/guzzle nyholm/psr7
# or
composer require symfony/http-client nyholm/psr7
Architecture
+-------------------+
event listener ---> | IndexNowService | ---> queues URLs
+-------------------+
|
v
+-----------------+
| UrlStore | (in-memory by default; swap for Redis, DB, ...)
+-----------------+
|
cron / scheduler ----+
|
v
+-----------------+ submit per engine, <= 10 000 URLs per call
| SubmitJob | --------------------------> +------------------+
+-----------------+ | IndexNowClient |
+------------------+
|
v
+------------------+
| search engines |
+------------------+
IndexNowService::submit(Url)adds a URL to theUrlStore.- The store accumulates URLs across the cron interval; duplicates are coalesced.
SubmitJob::run()drains the store, delivers it to every configured search engine, and only removes URLs after every engine accepts the batch.
Quick start
use GuzzleHttp\Client as GuzzleClient; use Nyholm\Psr7\Factory\Psr17Factory; use SlimAD\IndexNow\Client\HttpIndexNowClient; use SlimAD\IndexNow\Config\IndexNowConfig; use SlimAD\IndexNow\Config\SearchEngine; use SlimAD\IndexNow\Job\SubmitJob; use SlimAD\IndexNow\Service\IndexNowService; use SlimAD\IndexNow\Store\InMemoryUrlStore; use SlimAD\IndexNow\ValueObject\Host; use SlimAD\IndexNow\ValueObject\Key; use SlimAD\IndexNow\ValueObject\KeyLocation; use SlimAD\IndexNow\ValueObject\Url; $psr17 = new Psr17Factory(); $httpClient = new GuzzleClient(); $key = (string) getenv('INDEXNOW_KEY'); $config = new IndexNowConfig( new Host('example.com'), [ SearchEngine::indexNowApi( new Key($key), KeyLocation::fromString('https://example.com/' . $key . '.txt'), ), ], ); $store = new InMemoryUrlStore(); $service = new IndexNowService($store); $client = new HttpIndexNowClient($httpClient, $psr17, $psr17); $job = new SubmitJob($store, $client, $config); // Triggered by your "category updated" / "product created" listeners: $service->submit(new Url('https://example.com/products/42')); // Triggered by your scheduler (cron): $result = $job->run(); if ($result->hasFailures()) { foreach ($result->failures as $failure) { // log and let the next tick retry; the queue is preserved on failure error_log($failure->getMessage()); } }
Config
IndexNowConfig requires:
- a
Host— your canonical domain (example.com) - one or more
SearchEngineinstances — each with its ownKeyandKeyLocation
The package ships with factories for the well-known engines (SearchEngine::bing,
SearchEngine::yandex, SearchEngine::seznam, SearchEngine::naver, SearchEngine::yep,
SearchEngine::indexNowApi) and a generic constructor for custom endpoints. Engine names have
to be unique within one IndexNowConfig.
Participating engines forward submissions to each other, so a single endpoint —
SearchEngine::indexNowApi()— is usually enough. Configure several engines only when you deliberately want to notify them independently, for example because each one was verified with a different key.
A typical environment-driven config looks like:
INDEXNOW_HOST=example.com INDEXNOW_KEY=abcdef0123456789abcdef0123456789
KeyLocation must point at a file on your domain that returns the matching key as text/plain,
e.g. https://example.com/abcdef0123456789abcdef0123456789.txt. The key is deliberately public:
it is an ownership proof, not a credential.
Wiring listeners
The package itself does not depend on any framework. To wire it into your application:
- On every product creation, call
IndexNowService::submit(new Url($product->url())). - On every category cache invalidation / category update, call
IndexNowService::submit(new Url($category->url())).
The store deduplicates so calling submit() multiple times for the same URL within an interval is
safe.
Running the job
SubmitJob::run() is synchronous and side-effecting. Schedule it with whatever your project uses
(cron, Symfony Scheduler, Laravel Scheduler, supervised worker). The interval is up to you;
IndexNow recommends batching changes within minutes.
SubmitJob::run() returns a SubmitJobResult with:
submittedUrls— number of URLs in the batchdiscardedUrls— number of queued URLs that were dropped because they do not belong to the configured hostfailures—list<SubmitFailedException>, one per engine that rejected the batch or was unreachableisSuccess()/hasFailures()helpers
If any engine fails the job keeps the URLs in the store for the next run.
Batching
The IndexNow specification caps a single submission at 10 000 URLs. SubmitJob chunks the queue
accordingly; pass a smaller batch size if you prefer smaller requests:
$job = new SubmitJob($store, $client, $config, batchSize: 500);
Foreign URLs
IndexNow only accepts URLs that belong to the submitted host. Anything queued for a different
host is dropped by SubmitJob::run() and counted in SubmitJobResult::$discardedUrls, so one
mistyped URL cannot block the queue forever.
Custom storage
Replace InMemoryUrlStore with anything that implements UrlStore to persist URLs across
processes (Redis, database, Cache PSR-6/PSR-16, ...). The interface is small:
interface UrlStore { public function add(Url $url): void; /** @return list<Url> */ public function all(): array; public function remove(Url $url): void; public function clear(): void; public function count(): int; }
Custom HTTP transport
HttpIndexNowClient only depends on PSR-18 / PSR-17. Any compliant client works (Guzzle, Symfony
HTTP Client, Buzz, ...). It sends a User-Agent identifying this package; override it to identify
your own application:
$client = new HttpIndexNowClient($httpClient, $psr17, $psr17, 'acme-shop/2.1');
If you need a different request shape (e.g. the GET form with ?url=&key=), implement
IndexNowClient yourself — submit(SubmitRequest) is the entire contract.
Error handling
Everything the package throws extends SlimAD\IndexNow\Exception\IndexNowException:
| Exception | Thrown when |
|---|---|
InvalidUrlException |
a value is not an absolute http/https URL |
InvalidHostException |
a value is not a valid host name |
InvalidKeyException |
a key is shorter than 8 / longer than 128 chars or has bad characters |
InvalidConfigException |
the configuration or a submit request is inconsistent |
SubmitFailedException |
an endpoint was unreachable or rejected the submission |
SubmitFailedException carries the $endpoint, the $statusCode (null for transport errors)
and the raw $responseBody, so you can branch on them instead of parsing the message.
Notes and limitations
- Internationalised domains have to be supplied in punycode (
https://xn--wa-fka.pl/). Hostmatching is exact:example.comdoes not matchwww.example.com. Configure the host that your canonical URLs actually use.- The package does not retry on its own. A failed run leaves the URLs in the store, so the next scheduled run retries them.
Quality gates
This package targets:
- PHP 8.2, 8.3, 8.4 and 8.5, verified against both lowest and highest dependency versions
- PHPStan level 10 with strict rules
- 100% line coverage and a 100% mutation score (Infection)
- PSR-12 based coding standard enforced by PHP-CS-Fixer
composer auditand Dependabot for dependency hygiene
Local development:
composer install composer ci # cs + phpstan + test + infection composer test # PHPUnit only composer infection # requires xdebug or pcov
See CONTRIBUTING.md for the full workflow.
Roadmap
- Core package (this repo).
slimad/indexnow-laravel— service provider, config publishing, cache/database stores, queued job, Artisan commands, scheduler binding, model observer.
License
MIT — see LICENSE.