# indexnowkit for PHP Tell search engines (Bing, Yandex, Naver, Seznam, Yep, and the other participants of the IndexNow registry) about new, changed and deleted pages the moment a model is committed. One attribute on the model, one key, done. Google does not participate; IndexNow is a notification, not indexing. | Package | Framework | Install | |---|---|---| | [Symfony bundle](symfony-bundle/index.md) | Symfony 6.4 \| 7 \| 8 + Doctrine | `composer require indexnowkit/symfony-bundle indexnowkit/doctrine` | | [Laravel](laravel/index.md) | Laravel 12 \| 13 | `composer require indexnowkit/laravel` | | [Yii2](yii2/index.md) | Yii 2.0.45+ | `composer require indexnowkit/yii2` | | [Yii3](yii3/index.md) | yiisoft/active-record 1.x, yiisoft/db 2.x | `composer require indexnowkit/yii3` | | [Doctrine](doctrine/index.md) | Doctrine ORM without Symfony | `composer require indexnowkit/doctrine` | | [CLI](cli/index.md) | any site: cron on Bitrix, WordPress, MODX, OpenCart; static sites on deploy (PHAR, Docker image, GitHub Action) | `composer global require indexnowkit/cli` | | [Core](core/index.md) | plain PHP, any PSR-18 client | `composer require indexnowkit/core` | | [Sitemap](sitemap/index.md) | the `sitemap` command of every adapter | `composer require indexnowkit/sitemap` | | [Verify](verify/index.md) | a pre-flight GET before every submission, `check --sample` | `composer require indexnowkit/verify` | | [History](history/index.md) | the `history` and `status` commands, PSR-16 and PDO stores | `composer require indexnowkit/history` | Start with the page of your framework, then the [attribute reference](core/attribute-reference.md), the [configuration](core/configuration.md) (one concept, four keys: Symfony, Laravel, Yii2, Yii3) and the [operations guide](core/operations.md) with its production checklist. Machine-readable: [llms.txt](llms.txt), [llms-full.txt](llms-full.txt). Issues and pull requests: [github.com/indexnowkit/php](https://github.com/indexnowkit/php). # Symfony IndexNow bundle — `indexnowkit/symfony-bundle` Tell search engines about new, changed and deleted pages the moment a Doctrine entity is committed. One attribute on the entity, one env variable, done. [![Packagist](https://img.shields.io/packagist/v/indexnowkit/symfony-bundle)](https://packagist.org/packages/indexnowkit/symfony-bundle) [![Downloads](https://img.shields.io/packagist/dt/indexnowkit/symfony-bundle)](https://packagist.org/packages/indexnowkit/symfony-bundle) [![CI](https://github.com/indexnowkit/php/actions/workflows/ci.yml/badge.svg)](https://github.com/indexnowkit/php/actions) [![Conformance](https://img.shields.io/badge/conformance-core%2022%2F22%20%C2%B7%20orm%2014%2F14%20%C2%B7%20http%206%2F6-brightgreen)](https://github.com/indexnowkit/spec) ![PHPStan](https://img.shields.io/badge/phpstan-level%209-4c1) ![PHP](https://img.shields.io/badge/php-%5E8.2-777bb4) ![Symfony](https://img.shields.io/badge/symfony-6.4%20%7C%207.x-000) [![License](https://img.shields.io/packagist/l/indexnowkit/symfony-bundle)](https://github.com/indexnowkit/php/blob/main/packages/symfony-bundle/LICENSE) [Русская версия](https://github.com/indexnowkit/php/blob/main/packages/symfony-bundle/README.ru.md) · Issues and pull requests: [github.com/indexnowkit/php](https://github.com/indexnowkit/php/issues) (the `php-*` repositories are read-only splits) ## Who gets notified **Yandex, Bing (and DuckDuckGo via Bing), Naver, Seznam, Yep, Internet Archive, Amazon** — every engine in the [IndexNow](https://www.indexnow.org) [registry](https://www.indexnow.org/searchengines.json). One request to the shared endpoint reaches all of them; name engines explicitly only to reach a single one. **Google: no.** Google does not support IndexNow, its sitemap ping endpoint is gone (404) and the Indexing API is restricted to `JobPosting` / `BroadcastEvent`. This bundle will not pretend otherwise. **Notification, not indexing.** IndexNow tells an engine that a URL changed; whether and when the page is crawled and indexed is the engine's decision. See the result in Bing Webmaster Tools (IndexNow Insights) and Yandex.Webmaster (Indexing → Reindex pages); a useful metric is the share of submitted URLs in the index after a few days. Deleted pages: answer 410 (gone for good) or 404 (temporarily); for a move answer 301 and submit both URLs; a soft-404 or a redirect to the home page does harm. Bing's URL Submission API and Google's Indexing API are different protocols and not covered here. ## Why this over X Most IndexNow packages are a thin HTTP client: you collect the URLs, you call it, you read the answer. This family does the part that goes wrong in practice: - **Declared on the model** (`#[IndexNow]`) and submitted from the ORM hooks — no controller code to forget. - **After the commit**, not on flush: a rolled-back transaction announces nothing. - **Debounce** (10 minutes per URL, shared through your cache), **batches** of up to 10 000 URLs, one key per host from env. - **Answers handled**: 202 (key pending), 422, 429 with `Retry-After` back-off and a retry through your queue, 403 escalation. - **`check` before the first submission** says what is wrong (key file, engines, queue, cache, environment); `explain` says why a URL was or was not sent. - **One core** under the Symfony, Laravel, Yii2, Yii3 and Doctrine adapters with a shared conformance suite: the same behaviour everywhere, documented once. ## Install ```bash composer require indexnowkit/symfony-bundle composer require symfony/http-client nyholm/psr7 # any PSR-18 client works; this pair is auto-configured composer require indexnowkit/doctrine # for automatic submission when entities change composer require indexnowkit/sitemap # optional: the indexnow:sitemap command bin/console indexnow:key:generate --write-env # adds INDEXNOW_KEY to .env.local ``` The Flex recipe (a contrib recipe: `composer config extra.symfony.allow-contrib true` once) registers the bundle, creates `config/packages/indexnowkit.yaml`, imports the key file route and adds `INDEXNOW_KEY` (empty: dry run outside production until `indexnow:key:generate --write-env` fills `.env.local`) and `INDEXNOW_BASE_URL` to `.env`. Without Flex, add `IndexNowKit\SymfonyBundle\IndexNowKitBundle` to `config/bundles.php` and import `@IndexNowKitBundle/config/routes.php` from `config/routes.yaml`. ```yaml # config/packages/indexnowkit.yaml indexnowkit: key: '%env(INDEXNOW_KEY)%' base_url: '%env(INDEXNOW_BASE_URL)%' # used by console commands and Messenger workers ``` Entity hooks need `indexnowkit/doctrine` **and** `doctrine/doctrine-bundle`. Without them the bundle still works for manual submission, and `indexnow:check` says so instead of failing silently. ## Declare what has a public page `#[IndexNow]` is repeatable: one attribute per family of public URLs the entity has. ```php use Doctrine\ORM\Mapping as ORM; use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults}; #[ORM\Entity] #[IndexNowDefaults(when: 'isPublished', fields: ['slug', 'title', 'body', 'published'])] #[IndexNow(route: 'post_show', params: ['slug' => 'slug'])] #[IndexNow(route: 'post_amp', params: ['slug' => 'slug'], when: 'hasAmp')] #[IndexNow(via: 'category')] // a changed post also refreshes its category page #[IndexNow(urls: ['/'])] // and the homepage class Post { #[ORM\Id, ORM\GeneratedValue, ORM\Column] public ?int $id = null; #[ORM\ManyToOne] public ?Category $category = null; public function __construct( #[ORM\Column(unique: true)] public string $slug, #[ORM\Column] public string $title = '', #[ORM\Column(type: 'text')] public string $body = '', #[ORM\Column] public bool $published = true, #[ORM\Column] public bool $amp = false, ) {} public function isPublished(): bool { return $this->published; } public function hasAmp(): bool { return $this->amp; } } ``` | Option | Meaning | |---|---| | `route` / `params` | route name and `param => property, getter, "self", dotted.path` or a typed `Param\*` value | | `resolver` | a `UrlResolverInterface` service id or class for anything custom | | `via` | an accessor to a related object or collection whose pages are resubmitted | | `url` / `urls` | an accessor returning the URL(s), or literal URLs | | `when` / `whenFields` | bool accessor; unpublished entities are skipped and `published → draft` is sent as a deletion | | `fields` | for updates, submit only when one of these fields changed | | `events` | subset of `created`, `updated`, `deleted` | | `locales` | `current` (default), `all` (every `framework.enabled_locales`), or a list | | `host` | generate this rule's URLs on another host (multi-domain) | | `name` | stable rule id for logs, `indexnow:explain` and overriding in a subclass | Full model, typed parameters, inheritance and the semantics table: [core attribute reference](../core/attribute-reference.md). ## Verify ```bash bin/console indexnow:check # config, key file reachable, engines, dispatch, Doctrine hooks bin/console indexnow:check --live # also sends a real probe request to every engine ``` Run it after every key rotation and after every deployment that touches the configuration. It is the command that answers most "it does not work" reports on its own. ## How it works - URLs are collected in `onFlush` / `postFlush` and handed over **only after the outermost transaction commits** (a DBAL driver middleware watches the real COMMIT). Rolled-back changes are never submitted. - Every rule of an entity is classified separately: the article page can be an update while the AMP page of the same entity is a deletion, in the same flush. - Everything collected during one HTTP request, console command or Messenger message is sent as **one batch** after the response was sent (`kernel.terminate`), never inside your request. - `dispatch: auto` uses **Messenger** when a transport is configured, otherwise sends synchronously after the response. `sync` always sends on terminate. `none` collects and never sends, for applications that drain the collector themselves. - The same URL is not re-sent within **10 minutes** (`debounce.per_url`, stored in `cache.app`), batches are split at **10 000 URLs**, hosts are grouped, `202` is a success, `403` means the key file is wrong. - Failures are logged on the `indexnow` Monolog channel and never break your request. `http.timeout` (10 s) and `throttle.max_requests_per_minute` (60, per process) apply to the HTTP client the bundle builds on first use. ## Manual submission ```php public function __construct(private readonly IndexNowKit\IndexNowKit $indexNow) {} $this->indexNow->submit(['/posts/hello', 'https://www.example.com/about']); $this->indexNow->submitEntity($post); $this->indexNow->explain($post, IndexNowKit\Event::Updated); // which rule produced which URL ``` ## Commands | Command | Options | |---|---| | `indexnow:check` | `--live` send a real probe · `--host` check one host only · `--probe-url` page to probe when the root redirects | | `indexnow:submit ` | `-f, --force` ignore the debounce store · `--dry-run` · `--json` | | `indexnow:submit-entity [ids...]` | `--event=updated`, `created` or `deleted` · `--limit` (default 1000, when no ids) · `--explain` show rule → URL and send nothing · `-f, --force` · `--dry-run` · `--json` | | `indexnow:explain ` | `--event=updated`, `created` or `deleted` | | `indexnow:sitemap [sitemap]` | `--changed-since="1 day"` · `--allow-foreign-hosts` follow CDN-hosted parts · `-f, --force` · `--dry-run` list only · `--json` · `--no-verify` | | `indexnow:history` | `--host` · `--status=ok|pending|failed|skipped` · `--url` · `--since=2h|3d|2026-09-01` · `--limit` (default 50) · `--json` · `--purge[=days]` | | `indexnow:status` | `--json` | | `indexnow:key:generate` | `-l, --length` (8-128, default 32) · `--alphanumeric` · `--write-env[=FILE]` (default `.env.local`) · `--force` rotate an existing key | `` accepts an FQCN or a short `App\Entity` name. `indexnow:submit-entity` and `indexnow:explain` need Doctrine. ### Sitemaps `composer require indexnowkit/sitemap # optional: the indexnow:sitemap command` `indexnow:sitemap` with no argument reads `sitemap.url`, else `/sitemap.xml`; a local path or `file://` URL reads the file without the web server. XML and text sitemaps, indexes and gzip are handled by the [`indexnowkit/sitemap`](../sitemap/index.md) package; the command streams and submits every `batch.max_urls` URLs, so size is not a concern. `sitemap.enabled: false` removes the command; decorating `indexnowkit.sitemap_reader` shapes what it submits ([docs/extending.md](extending.md)). Without the package everything else works unchanged: `indexnow:sitemap` says `indexnowkit/sitemap is not installed: composer require indexnowkit/sitemap` and exits 1, `indexnow:check` prints `sitemap: not installed (…)`, a `sitemap` block left in the yaml still compiles and is ignored. Nothing is logged about it. ### History `composer require indexnowkit/history # optional: what was submitted, when, with what answer` ```yaml indexnowkit: history: store: pdo # null (default, nothing kept) | psr16 (the debounce cache pool) | pdo pdo: { service: default } # a Doctrine connection name or service id — or dsn: 'sqlite:%kernel.project_dir%/var/indexnow.sqlite' ``` Every `Result` the submitter produces — the sync flush, the Messenger worker, the commands, a URL skipped by `indexnowkit/verify` — is recorded: normalized URLs, host, engine, status, reason, HTTP code, the error message (never the response body or the key). `bin/console indexnow:history` lists them newest first (`--host`, `--status`, `--url`, `--since`, `--json`); `indexnow:history --purge` removes what is older than `history.retention_days` (a cron line); `bin/console indexnow:status` prints the switches, the dispatch mode with the Messenger transport, the debounce store, the 403 counter of every host, the last successful submission and the history size (`--json` for machines). The profiler panel gets a "Recent submissions" table. `pdo` needs the table: the migration is in the package's [docs/migrations.md](../history/migrations.md) (`Schema::sql()`); until it exists `indexnow:check` prints a `history.store` error and the submitter logs the failure without breaking the request. `psr16` is a ring buffer of `history.limit` records for one process and small sites. Your own `Submission\SubmissionStoreInterface` registered as `indexnowkit.submission_store` takes precedence over either. Without the package `indexnow:history` and `indexnow:status` say `indexnowkit/history is not installed: composer require indexnowkit/history` and exit 1, `indexnow:check` prints `history: not installed (…)`. ## Configuration The full annotated tree, every default and every compile-time validation: [docs/configuration.md](configuration.md). | Topic | | |---|---| | Multiple domains | [docs/multi-domain.md](multi-domain.md) | | Async delivery and retries | [docs/messenger.md](messenger.md) | | HTTP client, proxy, scoped clients | [docs/http-client.md](http-client.md) | | Doctrine details, priorities, connections | [docs/doctrine.md](doctrine.md) | | Custom resolvers | [docs/custom-resolvers.md](custom-resolvers.md) | | Extending: what is replaceable, decorating services | [docs/extending.md](extending.md) | | Testing your integration | [docs/testing.md](testing.md) | | Troubleshooting | [docs/troubleshooting.md](troubleshooting.md) | ## Operations - [Production checklist](../core/operations.md#production-checklist) — key and base URL, `check` in the deploy pipeline, `strict_hosts`, a shared debounce store, a monitored queue, staging that cannot submit, the three lines to alert on. - [Monitoring rules and the Sentry filter](../core/operations.md#monitoring-rules), [deleted pages](../core/operations.md#deleted-pages-what-your-site-must-return), [what not to submit](../core/operations.md#what-not-to-submit). - [Multi-domain: hosts, www and apex, hreflang](multi-domain.md) · [troubleshooting](troubleshooting.md). ## Debugging Three tools, in the order you should reach for them. 1. **`bin/console indexnow:explain 'App\Entity\Post' 42`** walks the whole decision path for one entity — rules, event subscription, `when` guard, `fields` filter, resolved URLs, normalization, host and key, key file, debounce — and sends nothing. 2. **The Web Profiler panel** shows what the request collected, what was actually sent, and the HTTP outcome per engine, alongside the dispatch mode, the key file URL per host and the debounce window. 3. **The `indexnow` Monolog channel** carries everything. Set it to `debug` while diagnosing: the reason a rule decided *not* to produce a URL is logged there. Message texts and levels are listed in the [operations guide](../core/operations.md). An invalid configuration does not throw from a flush: IndexNow is disabled, one `critical` line is logged, and `indexnow:check` prints the exact error. ## Limitations - DQL and QueryBuilder bulk `UPDATE` / `DELETE` bypass the unit of work: use `indexnow:submit` or `$indexNow->submit()`. - Sub-domains are separate hosts: give each its own key with the `hosts` map, and set `strict_hosts: true` so a host you did not configure is skipped rather than announced under the default key. - `dispatch: sync` depends on `kernel.terminate` actually firing. An early `exit()`, a fatal error, or a worker runtime whose bridge does not dispatch it per request will discard the batch — with a warning. Under Swoole, RoadRunner or FrankenPHP prefer `dispatch: messenger`. - Long-running custom commands should call `$indexNow->flush()` periodically instead of accumulating URLs for the whole process lifetime. - Outside production (`production_environments`, default `prod`/`production`), a missing `INDEXNOW_KEY` switches `dry_run` on instead of failing, so dev and test never hit the real API. - A renamed page (changed slug) announces its old URL as deleted and the new one as updated in the same flush; an entity whose slug is a `readonly` property only gets the new URL (logged at `debug`). ## Compatibility Public API of the bundle: configuration nodes, command names and options, service ids and aliases listed in [docs/extending.md](extending.md), the `Console\*Interface`s of `indexnowkit/console` and the core's `Adapter\SubmitterFactoryInterface` they are aliased to, the Messenger message and handler, and the container parameters listed in [docs/configuration.md](configuration.md). `DependencyInjection\*` is wiring, not API. The core's rules apply, including the "may grow" interfaces: [bc.md](../core/bc.md); what this package itself keeps stable: [docs/bc.md](bc.md). Before 1.0 a minor version may break; every break is listed under "Changed" in [CHANGELOG.md](https://github.com/indexnowkit/php/blob/main/packages/symfony-bundle/CHANGELOG.md) with the migration. ## Notes for AI assistants - Composer package `indexnowkit/symfony-bundle` (Symfony 6.4 | 7 | 8, on `indexnowkit/core`); entity hooks need `indexnowkit/doctrine` + `doctrine/doctrine-bundle`; the `indexnow:sitemap` command needs `indexnowkit/sitemap`; pre-flight checks need `indexnowkit/verify`; `indexnow:history` / `indexnow:status` need `indexnowkit/history` (`history.store: psr16|pdo`). Configuration: `config/packages/indexnowkit.yaml`, root key `indexnowkit`. `bin/console indexnow:key:generate --env-file=.env.local` writes a fresh `INDEXNOW_KEY`; `bin/console indexnow:submit …` submits URLs by hand, `bin/console indexnow:explain ` shows why a URL is or is not produced. - Minimal complete snippet (every `use` included): ```php use Doctrine\ORM\Mapping as ORM; use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults}; #[ORM\Entity] #[IndexNowDefaults(when: 'isPublished', fields: ['slug', 'title', 'published'])] #[IndexNow(route: 'post_show', params: ['slug' => 'slug'])] #[IndexNow(urls: ['/'])] class Post { /* ORM columns, isPublished() */ } ``` - Verify: `bin/console indexnow:check` (exit 1 on any error; `--strict` fails on warnings too, `--json` for machines), `bin/console indexnow:config --json` (the effective configuration, keys masked: paste it into a bug report), `bin/console indexnow:explain 'App\\Entity\\Post' 1` (why a URL was or was not produced), `bin/console indexnow:submit-entity 'App\\Entity\\Post' 1 --dry-run`. - Pitfalls: - `dispatch: auto` exists in Symfony (`auto` | `messenger` | `sync` | `none`) and Yii2 (`auto` | `queue` | `sync` | `none`), **not** in Laravel (`queue` | `sync` | `none`); Yii3 has `sync` | `none` only. - Locales: `router.locales` in Laravel, Yii2 and Yii3, `framework.enabled_locales` in Symfony; `locales: 'all'` on a rule uses that list. - `url:` names an accessor (method or property) that returns the URL; `urls:` is a list of literal URLs. Never put a literal in `url:`. - A string in `when:` is an accessor read as truthy (`published`, `isPublished`). A status string needs `Equals`: `when: new Equals('status', 'published')` (`IndexNowKit\Attribute\Param\Equals`). - Manual submission is `submitEntity()` in Symfony, `submitModel()` in Laravel, `submitRecord()` in Yii2 and Yii3; the commands are `indexnow:submit-entity`, `indexnow:submit-model`, `indexnow/submit-record` (Yii2), `indexnow:submit-record` (Yii3). Bulk queries (`update()`, `DB::table()`, `updateAll()`) fire no hooks: submit afterwards with those. - Laravel has two classes called `IndexNowKit`: the facade `IndexNowKit\Laravel\Facades\IndexNowKit` and the core service `IndexNowKit\IndexNowKit` (inject by type). Yii2 exposes the core through `Yii::$app->indexnow->kit()`; Yii3 defines `IndexNowKit\IndexNowKit` in the container. - Outside production a configured key with `dry_run` unset makes `check` fail (a staging copy would submit real URLs): set `dry_run: true` there, or `dry_run: false` explicitly when it submits on purpose. - Unknown configuration keys are warned about at boot (typos such as debounce.per_urls); the key list is `Config::OPTIONS` plus the adapter's own keys. ## Other frameworks | | | |---|---| | PHP | [core](../core/index.md), [doctrine](../doctrine/index.md), [laravel](../laravel/index.md), [yii2](../yii2/index.md), [yii3](../yii3/index.md) | | JS/TS | @indexnowkit/core, next, prisma (soon) | | Python | indexnowkit, indexnowkit-django (soon) | Design rationale: [docs/spec](https://github.com/indexnowkit/spec). Changelog: [CHANGELOG.md](https://github.com/indexnowkit/php/blob/main/packages/symfony-bundle/CHANGELOG.md). MIT. IndexNow is a trademark of its owner; this project is independent and not affiliated with Microsoft, Yandex or indexnow.org. # Laravel IndexNow package — `indexnowkit/laravel` Tell search engines about new, changed and deleted pages the moment an Eloquent model is committed. One attribute on the model, one env variable, done. [![Packagist](https://img.shields.io/packagist/v/indexnowkit/laravel)](https://packagist.org/packages/indexnowkit/laravel) [![Downloads](https://img.shields.io/packagist/dt/indexnowkit/laravel)](https://packagist.org/packages/indexnowkit/laravel) [![CI](https://github.com/indexnowkit/php/actions/workflows/ci.yml/badge.svg)](https://github.com/indexnowkit/php/actions) [![Conformance](https://img.shields.io/badge/conformance-core%2022%2F22%20%C2%B7%20orm%2021%2F21%20%C2%B7%20http%206%2F6-brightgreen)](https://github.com/indexnowkit/spec) ![PHPStan](https://img.shields.io/badge/phpstan-level%209-4c1) ![PHP](https://img.shields.io/badge/php-%5E8.2-777bb4) ![Laravel](https://img.shields.io/badge/laravel-12%20%7C%2013-ff2d20) [![License](https://img.shields.io/packagist/l/indexnowkit/laravel)](https://github.com/indexnowkit/php/blob/main/packages/laravel/LICENSE) [Русская версия](https://github.com/indexnowkit/php/blob/main/packages/laravel/README.ru.md) · Issues and pull requests: [github.com/indexnowkit/php](https://github.com/indexnowkit/php/issues) (the `php-*` repositories are read-only splits) ## Who gets notified **Yandex, Bing (and DuckDuckGo via Bing), Naver, Seznam, Yep, Internet Archive, Amazon** — every engine in the [IndexNow](https://www.indexnow.org) [registry](https://www.indexnow.org/searchengines.json). One request to the shared endpoint reaches all of them; name engines explicitly only to reach a single one. **Google: no.** Google does not support IndexNow, its sitemap ping endpoint is gone (404) and the Indexing API is restricted to `JobPosting` / `BroadcastEvent`. This package will not pretend otherwise. **Notification, not indexing.** IndexNow tells an engine that a URL changed; whether and when the page is crawled and indexed is the engine's decision. See the result in Bing Webmaster Tools (IndexNow Insights) and Yandex.Webmaster (Indexing → Reindex pages); a useful metric is the share of submitted URLs in the index after a few days. Deleted pages: answer 410 (gone for good) or 404 (temporarily); for a move answer 301 and submit both URLs; a soft-404 or a redirect to the home page does harm. Bing's URL Submission API and Google's Indexing API are different protocols and not covered here. ## Why this over X Most IndexNow packages are a thin HTTP client: you collect the URLs, you call it, you read the answer. This family does the part that goes wrong in practice: - **Declared on the model** (`#[IndexNow]`) and submitted from the ORM hooks — no controller code to forget. - **After the commit**, not on flush: a rolled-back transaction announces nothing. - **Debounce** (10 minutes per URL, shared through your cache), **batches** of up to 10 000 URLs, one key per host from env. - **Answers handled**: 202 (key pending), 422, 429 with `Retry-After` back-off and a retry through your queue, 403 escalation. - **`check` before the first submission** says what is wrong (key file, engines, queue, cache, environment); `explain` says why a URL was or was not sent. - **One core** under the Symfony, Laravel, Yii2, Yii3 and Doctrine adapters with a shared conformance suite: the same behaviour everywhere, documented once. ## Install ```bash composer require indexnowkit/laravel composer require indexnowkit/sitemap # optional: the indexnow:sitemap command php artisan vendor:publish --tag=indexnow-config # config/indexnow.php (optional, every key has a default) php artisan indexnow:key:generate --write-env # adds INDEXNOW_KEY to .env php artisan indexnow:check # config, key file reachable, queue, cache ``` The service provider is auto-discovered. Laravel ships Guzzle, which is the PSR-18 client the package discovers; any other PSR-18 client works too (`indexnow.http.client`). ```dotenv INDEXNOW_KEY=... # from key:generate INDEXNOW_BASE_URL=https://www.example.com # defaults to APP_URL; used by artisan and queue workers ``` ## Declare what has a public page `#[IndexNow]` is repeatable: one attribute per family of public URLs the model has. `IndexNowable` registers the observer. ```php use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults}; use IndexNowKit\Laravel\Eloquent\IndexNowable; /** * @property string $slug * @property bool $published * @property bool $amp */ #[IndexNowDefaults(when: 'isPublished', fields: ['slug', 'title', 'body', 'published'])] #[IndexNow(route: 'posts.show', params: ['post' => 'self'])] // route model binding #[IndexNow(route: 'posts.amp', params: ['slug' => 'slug'], when: 'hasAmp')] #[IndexNow(via: 'category')] // a changed post also refreshes its category page #[IndexNow(urls: ['/'])] // and the homepage class Post extends Model { use IndexNowable; /** @var array */ protected $casts = ['published' => 'bool', 'amp' => 'bool']; public function isPublished(): bool { return $this->published; } public function hasAmp(): bool { return $this->amp; } /** @return BelongsTo */ public function category(): BelongsTo { return $this->belongsTo(Category::class); } } ``` | Option | Meaning | |---|---| | `route` / `params` | route name and `param => attribute, method, "self", dotted.path` or a typed `Param\*` value | | `resolver` | a `UrlResolverInterface` class or container binding for anything custom | | `via` | a relation (or dotted path) whose pages are resubmitted | | `url` / `urls` | a method returning the URL(s), or literal URLs | | `when` / `whenFields` | bool attribute or method; drafts are skipped and `published → draft` is sent as a deletion | | `fields` | for updates, submit only when one of these attributes changed | | `events` | subset of `created`, `updated`, `deleted` | | `locales` | `current` (default), `all` (`indexnow.router.locales`), or a list | | `host` | generate this rule's URLs on another host (multi-domain) | | `name` | stable rule id for logs, `indexnow:explain` and overriding in a subclass | Accessors read Eloquent attributes, casts, accessors and relations (`category.slug`) and fall back to methods (`isPublished()`). `params: ['post' => 'self']` passes the model to `route()`, so `{post}` and `{post:slug}` both work. A `when` attribute that only has a **database** default is not on the model right after `create()`: give it a model default (`protected $attributes = ['published' => false]`). Full model, typed parameters, inheritance and the semantics table: [core attribute reference](../core/attribute-reference.md). ### Models you cannot annotate ```php // AppServiceProvider::boot() use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults, RuleSet}; use IndexNowKit\Laravel\Facades\IndexNowKit; IndexNowKit::observe(Product::class, [new IndexNow(route: 'products.show', params: ['product' => 'self'])], new IndexNowDefaults(when: 'is_active')); IndexNowKit::rules()->registerFor(Page::class, fn (Page $page): ?RuleSet => ...); // decided per object ``` Two classes are called `IndexNowKit`. The **facade** `IndexNowKit\Laravel\Facades\IndexNowKit` (above) proxies the `IndexNowManager` of this package: `observe()`, `rules()`, `submitModel()`, `submitModels()`, `submit()`, `collect()`, `flush()`, `explain()`. The **core** `IndexNowKit\IndexNowKit` is the same service without the Eloquent-specific parts; inject it by type (`public function __construct(private IndexNowKit $indexNow)`) or take it from the facade with `IndexNowKit::kit()`. Import one of them per file, or alias the other. ## Verify ```bash php artisan indexnow:check # config, key file reachable, engines, queue connection, cache store, spool php artisan indexnow:check --live # also sends a real probe request to every engine ``` Run it after every key rotation and after every deployment that touches the configuration. ## How it works - Observer callbacks resolve URLs **while the old state is still live** (`getOriginal()` in `updated`, the row in `deleting`) and hand them over through `Connection::afterCommit()`: nothing leaves before the outermost transaction commits, a rolled-back transaction (or savepoint) discards them. `DB::transaction()` nesting is handled by Laravel's transaction manager. - Every rule is classified separately: the article page can be an update while the AMP page of the same model is a deletion, in the same request. - Everything collected during one request, artisan command or queue job is sent as **one batch** in `app()->terminating()` (or after each handled job), never inside your request. - `dispatch: queue` (the default) pushes a `SubmitUrlsJob`; 429 and 5xx are retried with backoff, `Retry-After` wins, 403/422 fail the job so a broken key file shows up in `failed_jobs`. `QUEUE_CONNECTION=sync` runs it inline. - `SoftDeletes`: soft delete is a deletion, `restore()` a creation, `forceDelete()` a deletion. - A renamed page (changed slug, or a changed route key behind `self`) announces its old URL as deleted and the new one as updated, in the same batch. - Nothing thrown from a rule, a resolver or the HTTP layer reaches your application: it is logged, the save succeeds. ## Commands | Command | Options | |---|---| | `indexnow:check` | `--live` real probe · `--host=` one host · `--probe-url=` page for the probe | | `indexnow:submit ` | `-f, --force` ignore debounce · `--dry-run` · `--json` | | `indexnow:submit-model [ids...]` | `--event=` · `--limit=` · `--explain` · `-f, --force` · `--dry-run` · `--json` | | `indexnow:explain ` | `--event=` — rules, `when`, URLs, key, debounce; sends nothing | | `indexnow:sitemap [sitemap]` | `--changed-since="1 day"` · `--allow-foreign-hosts` · `-f, --force` · `--dry-run` · `--json` · `--no-verify` | | `indexnow:history` | `--host=` · `--status=ok|pending|failed|skipped` · `--url=` · `--since=2h|3d|2026-09-01` · `--limit=` (default 50) · `--json` · `--purge[=days]` | | `indexnow:status` | `--json` | | `indexnow:key:generate` | `-l, --length` · `--alphanumeric` · `--write-env[=FILE]` (default `.env`) · `--force` rotate | `` accepts an FQCN or a short `App\Models` name. ### Sitemaps `composer require indexnowkit/sitemap # optional: the indexnow:sitemap command` `indexnow:sitemap` with no argument reads `indexnow.sitemap.url`, else `/sitemap.xml`; a local path works too. Schedule it: `Schedule::command('indexnow:sitemap --changed-since="1 day"')->daily()`. Without the package everything else works unchanged: `indexnow:sitemap` says `indexnowkit/sitemap is not installed: composer require indexnowkit/sitemap` and exits 1, `indexnow:check` prints `sitemap: not installed (…)`, the `sitemap` block of `config/indexnow.php` is ignored. Nothing is logged about it. Details: [docs/sitemap.md](sitemap.md). ### History `composer require indexnowkit/history # optional: what was submitted, when, with what answer` ```php // config/indexnow.php 'history' => [ 'store' => env('INDEXNOW_HISTORY_STORE'), // null (default, nothing kept) | psr16 (the debounce cache store) | pdo 'pdo' => ['service' => null], // a connection of config/database.php (null = default) — or 'dsn' => 'sqlite:/var/data/indexnow.sqlite' ], ``` Every `Result` the submitter produces — the flush in `app()->terminating()`, the queue job, the commands, a URL skipped by `indexnowkit/verify` — is recorded: normalized URLs, host, engine, status, reason, HTTP code, the error message (never the response body or the key). `php artisan indexnow:history` lists them newest first (`--host`, `--status`, `--url`, `--since`, `--json`); `indexnow:history --purge` removes what is older than `history.retention_days` (`Schedule::command('indexnow:history --purge')->daily()`); `php artisan indexnow:status` prints the switches, the dispatch mode with the queue connection and queue, the debounce store, the 403 counter of every host, the last successful submission and the history size (`--json` for machines); `php artisan about` gets a `History` line. `pdo` needs the table: the migration is in the package's [docs/migrations.md](../history/migrations.md) (`Schema::sql()`); until it exists `indexnow:check` prints a `history.store` error and the submitter logs the failure without breaking the flush. `psr16` is a ring buffer of `history.limit` records for one process and small sites. A `Submission\SubmissionStoreInterface` you bind yourself takes precedence over either. Without the package `indexnow:history` and `indexnow:status` say `indexnowkit/history is not installed: composer require indexnowkit/history` and exit 1, `indexnow:check` prints `history: not installed (…)`. ## Configuration Every key of `config/indexnow.php`, its default and what it does: [docs/configuration.md](configuration.md). | Topic | | |---|---| | Queue, retries, Horizon | [docs/queue.md](queue.md) | | Multiple domains and locales | [docs/multi-domain.md](multi-domain.md) | | Sitemaps | [docs/sitemap.md](sitemap.md) | | Extending: bindings you can replace, custom resolvers, checks | [docs/extending.md](extending.md) | | Testing your integration | [docs/testing.md](testing.md) | | Troubleshooting | [docs/troubleshooting.md](troubleshooting.md) | ## Operations - [Production checklist](../core/operations.md#production-checklist) — key and base URL, `check` in the deploy pipeline, `strict_hosts`, a shared debounce store, a monitored queue, staging that cannot submit, the three lines to alert on. - [Monitoring rules and the Sentry filter](../core/operations.md#monitoring-rules), [deleted pages](../core/operations.md#deleted-pages-what-your-site-must-return), [what not to submit](../core/operations.md#what-not-to-submit). - [Multi-domain: hosts, www and apex, hreflang](multi-domain.md) · [queue](queue.md) · [troubleshooting](troubleshooting.md). ## Debugging 1. **`php artisan indexnow:explain "App\Models\Post" 42`** walks the decision path for one model — rules, event subscription, `when`, `fields`, resolved URLs, normalization, host and key, debounce — and sends nothing. 2. **The log channel** (`indexnow.logging.channel`, default channel otherwise) carries everything; at `debug` it also says why a rule decided *not* to produce a URL. Messages and levels: [operations guide](../core/operations.md). 3. **`failed_jobs`** holds batches an engine rejected permanently (403: key file not reachable). An invalid configuration does not throw from a save: IndexNow is disabled, one `critical` line is logged, and `indexnow:check` prints the exact error. ## Limitations - `Model::query()->update()`, `delete()`, `insert()`, `upsert()` and `DB::table()` fire no model events (conformance A13): call `IndexNowKit::submitModels($query->get())` or `php artisan indexnow:submit-model` afterwards. - `attach()` / `detach()` / `sync()` on a pivot fire no events on the owner. Put `$touches = ['posts']` on the related model: the owner's `updated` (only `updated_at` changed) reaches a rule without a `fields` filter. - `dispatch: sync` depends on `terminating` firing. Under Octane it does; an early `exit()` or a fatal error discards the batch with a warning. Prefer the default `queue`. - Sub-domains are separate hosts: give each its own key with the `hosts` map, and set `strict_hosts: true`. - Outside production (`production_environments`, default `prod`/`production`), a missing `INDEXNOW_KEY` switches `dry_run` on instead of failing. ## Compatibility Public API: `config/indexnow.php` keys, command names and options, the container bindings listed in [docs/extending.md](extending.md), `Facades\IndexNowKit` / `IndexNowManager`, `Eloquent\IndexNowable`, `Queue\SubmitUrlsJob`. The core's rules apply, including the "may grow" interfaces: [bc.md](../core/bc.md); what this package itself keeps stable: [docs/bc.md](bc.md). Before 1.0 a minor version may break; every break is listed under "Changed" in [CHANGELOG.md](https://github.com/indexnowkit/php/blob/main/packages/laravel/CHANGELOG.md) with the migration. Laravel 12 and 13, PHP 8.2–8.5 (Laravel 13 needs PHP 8.3). ## Notes for AI assistants - Composer package `indexnowkit/laravel` (Laravel 12 | 13, on `indexnowkit/core`); the `indexnow:sitemap` command needs `indexnowkit/sitemap`; pre-flight checks need `indexnowkit/verify`; `indexnow:history` / `indexnow:status` need `indexnowkit/history` (`history.store: psr16|pdo`). Configuration: `config/indexnow.php` and `INDEXNOW_*` env variables; `php artisan indexnow:key:generate --env-file=.env` writes a fresh `INDEXNOW_KEY`; `php artisan indexnow:submit …` submits URLs by hand, `php artisan indexnow:explain ` shows why a URL is or is not produced. - Minimal complete snippet (every `use` included): ```php use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults}; use IndexNowKit\Laravel\Eloquent\IndexNowable; #[IndexNowDefaults(when: 'isPublished', fields: ['slug', 'title', 'published'])] #[IndexNow(route: 'posts.show', params: ['post' => 'self'])] // route model binding #[IndexNow(urls: ['/'])] class Post extends Model { use IndexNowable; public function isPublished(): bool { return (bool) $this->published; } } ``` - Verify: `php artisan indexnow:check` (exit 1 on any error; `--strict` fails on warnings too, `--json` for machines), `php artisan indexnow:config --json` (the effective configuration, keys masked: paste it into a bug report), `php artisan indexnow:explain 'App\\Models\\Post' 1` (why a URL was or was not produced), `php artisan indexnow:submit-model 'App\\Models\\Post' 1 --dry-run`. - Pitfalls: - `dispatch: auto` exists in Symfony (`auto` | `messenger` | `sync` | `none`) and Yii2 (`auto` | `queue` | `sync` | `none`), **not** in Laravel (`queue` | `sync` | `none`); Yii3 has `sync` | `none` only. - Locales: `router.locales` in Laravel, Yii2 and Yii3, `framework.enabled_locales` in Symfony; `locales: 'all'` on a rule uses that list. - `url:` names an accessor (method or property) that returns the URL; `urls:` is a list of literal URLs. Never put a literal in `url:`. - A string in `when:` is an accessor read as truthy (`published`, `isPublished`). A status string needs `Equals`: `when: new Equals('status', 'published')` (`IndexNowKit\Attribute\Param\Equals`). - Manual submission is `submitEntity()` in Symfony, `submitModel()` in Laravel, `submitRecord()` in Yii2 and Yii3; the commands are `indexnow:submit-entity`, `indexnow:submit-model`, `indexnow/submit-record` (Yii2), `indexnow:submit-record` (Yii3). Bulk queries (`update()`, `DB::table()`, `updateAll()`) fire no hooks: submit afterwards with those. - Laravel has two classes called `IndexNowKit`: the facade `IndexNowKit\Laravel\Facades\IndexNowKit` and the core service `IndexNowKit\IndexNowKit` (inject by type). Yii2 exposes the core through `Yii::$app->indexnow->kit()`; Yii3 defines `IndexNowKit\IndexNowKit` in the container. - Outside production a configured key with `dry_run` unset makes `check` fail (a staging copy would submit real URLs): set `dry_run: true` there, or `dry_run: false` explicitly when it submits on purpose. - Unknown configuration keys are warned about at boot (typos such as debounce.per_urls); the key list is `Config::OPTIONS` plus the adapter's own keys. ## Other frameworks | | | |---|---| | PHP | [core](../core/index.md), [symfony-bundle](../symfony-bundle/index.md), [doctrine](../doctrine/index.md), [laravel](./index.md), [yii2](../yii2/index.md), [yii3](../yii3/index.md) | | JS/TS | @indexnowkit/core, next, prisma (soon) | | Python | indexnowkit, indexnowkit-django (soon) | Design rationale: [docs/spec](https://github.com/indexnowkit/spec). Changelog: [CHANGELOG.md](https://github.com/indexnowkit/php/blob/main/packages/laravel/CHANGELOG.md). MIT. IndexNow is a trademark of its owner; this project is independent and not affiliated with Microsoft, Yandex or indexnow.org. # Yii2 IndexNow extension — `indexnowkit/yii2` Tell search engines about new, changed and deleted pages the moment an ActiveRecord row is committed. One attribute on the model, one component, done. [![Packagist](https://img.shields.io/packagist/v/indexnowkit/yii2)](https://packagist.org/packages/indexnowkit/yii2) [![Downloads](https://img.shields.io/packagist/dt/indexnowkit/yii2)](https://packagist.org/packages/indexnowkit/yii2) [![CI](https://github.com/indexnowkit/php/actions/workflows/ci.yml/badge.svg)](https://github.com/indexnowkit/php/actions) [![Conformance](https://img.shields.io/badge/conformance-core%2022%2F22%20%C2%B7%20orm%2021%2F21%20%C2%B7%20http%206%2F6-brightgreen)](https://github.com/indexnowkit/spec) ![PHPStan](https://img.shields.io/badge/phpstan-level%209-4c1) ![PHP](https://img.shields.io/badge/php-%5E8.2-777bb4) ![Yii](https://img.shields.io/badge/yii-2.0.45%2B-1a73e8) [![License](https://img.shields.io/packagist/l/indexnowkit/yii2)](https://github.com/indexnowkit/php/blob/main/packages/yii2/LICENSE) [Русская версия](https://github.com/indexnowkit/php/blob/main/packages/yii2/README.ru.md) · Issues and pull requests: [github.com/indexnowkit/php](https://github.com/indexnowkit/php/issues) (the `php-*` repositories are read-only splits) ## Who gets notified **Yandex, Bing (and DuckDuckGo via Bing), Naver, Seznam, Yep, Internet Archive, Amazon** — every engine in the [IndexNow](https://www.indexnow.org) [registry](https://www.indexnow.org/searchengines.json). One request to the shared endpoint reaches all of them; name engines explicitly only to reach a single one. **Google: no.** Google does not support IndexNow; this package will not pretend otherwise. **Notification, not indexing.** IndexNow tells an engine that a URL changed; whether and when the page is crawled and indexed is the engine's decision. See the result in Bing Webmaster Tools (IndexNow Insights) and Yandex.Webmaster (Indexing → Reindex pages); a useful metric is the share of submitted URLs in the index after a few days. Deleted pages: answer 410 (gone for good) or 404 (temporarily); for a move answer 301 and submit both URLs; a soft-404 or a redirect to the home page does harm. Bing's URL Submission API and Google's Indexing API are different protocols and not covered here. ## Why this over X Most IndexNow packages are a thin HTTP client: you collect the URLs, you call it, you read the answer. This family does the part that goes wrong in practice: - **Declared on the model** (`#[IndexNow]`) and submitted from the ORM hooks — no controller code to forget. - **After the commit**, not on flush: a rolled-back transaction announces nothing. - **Debounce** (10 minutes per URL, shared through your cache), **batches** of up to 10 000 URLs, one key per host from env. - **Answers handled**: 202 (key pending), 422, 429 with `Retry-After` back-off and a retry through your queue, 403 escalation. - **`check` before the first submission** says what is wrong (key file, engines, queue, cache, environment); `explain` says why a URL was or was not sent. - **One core** under the Symfony, Laravel, Yii2, Yii3 and Doctrine adapters with a shared conformance suite: the same behaviour everywhere, documented once. ## Install ```bash composer require indexnowkit/yii2 symfony/http-client nyholm/psr7 # any PSR-18 client + PSR-17 factories work composer require indexnowkit/sitemap # optional: the indexnow/sitemap command ``` ```php // config/web.php and config/console.php 'bootstrap' => ['indexnow'], // registers the console controller and the key file route 'components' => [ 'indexnow' => [ 'class' => \IndexNowKit\Yii2\IndexNowComponent::class, 'options' => [ 'key' => getenv('INDEXNOW_KEY'), 'base_url' => 'https://www.example.com', // used by console commands and queue workers 'dry_run' => YII_ENV_DEV, // dev/staging: log the request, send nothing (check fails when this is unset outside production) ], ], ], ``` ```bash php yii indexnow/key-generate --write-env # writes INDEXNOW_KEY=… to .env (or prints the key) php yii indexnow/check # options, key file reachable, queue, cache, URL rules ``` Yii2 does not read `.env` by itself: export the variable (`export INDEXNOW_KEY=…`), put it in the web server or container environment, or load the file with `vlucas/phpdotenv` before `config/*.php` runs — `getenv('INDEXNOW_KEY')` returns `false` until one of these is done, and `check` says `no key configured`. In `yii2-app-basic`, `config/web.php` and `config/console.php` are independent: configure the `indexnow` component **and** `urlManager` (pretty URLs, rules) in both, or `check`, `explain` and `submit-record` see a different setup than the web application. Pretty URLs (`urlManager.enablePrettyUrl`) are required for the key file route `/.txt`. The package needs a PSR-18 client (`symfony/http-client` + `nyholm/psr7` as above, or Guzzle); it discovers one, or takes the component/class named in `http.client`. ## Declare what has a public page `#[IndexNow]` is repeatable: one attribute per family of public URLs. `IndexNowBehavior` registers the hooks. Save the example as `models/Post.php` under `namespace app\models;` — it reads the columns `slug`, `title`, `body`, `published`, `amp` (the AMP page exists while it is true) and `category_id`; `Category` is a record of your own with its own `#[IndexNow]` rule (drop the `via: 'category'` line if you have none). ```php use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults}; use IndexNowKit\Yii2\ActiveRecord\IndexNowBehavior; use yii\db\ActiveQuery; use yii\db\ActiveRecord; #[IndexNowDefaults(when: 'published', fields: ['slug', 'title', 'body', 'published'])] #[IndexNow(route: 'post/view', params: ['slug' => 'slug'])] #[IndexNow(route: 'post/amp', params: ['slug' => 'slug'], when: 'amp')] #[IndexNow(via: 'category')] // a changed post also refreshes its category page #[IndexNow(urls: ['/'])] // and the homepage final class Post extends ActiveRecord { public static function tableName(): string { return 'posts'; } public function init(): void { parent::init(); $this->loadDefaultValues(); // `published` has a database default: make it visible before the first save } public function behaviors(): array { return [IndexNowBehavior::class]; } public function getCategory(): ActiveQuery { return $this->hasOne(Category::class, ['id' => 'category_id']); } } ``` | Option | Meaning | |---|---| | `route` / `params` | a Yii route (`controller/action`) and `param => attribute, method, "self", dotted.path` (`self` = the primary key) | | `resolver` | a `UrlResolverInterface` class or component id for anything custom | | `via` | a relation (or dotted path) whose pages are resubmitted | | `url` / `urls` | a method returning the URL(s), or literal URLs | | `when` / `whenFields` | bool attribute or method; drafts are skipped and `published → draft` is sent as a deletion | | `fields` | for updates, submit only when one of these attributes changed | | `events`, `locales`, `host`, `name` | subset of events; `current`/`all`/list (`router.locales`); another host; stable rule id | Accessors read ActiveRecord attributes and relations (`category.slug`) and fall back to methods. A `when` column that only has a **database** default is null on a fresh record: call `$this->loadDefaultValues()` in `init()` or set the attribute before `save()`. Classes you cannot annotate: `'active_record' => ['models' => [Product::class]]` in the options, or `Yii::$app->indexnow->observe(Product::class, [new IndexNow(...)])` at runtime. Full model, typed parameters, inheritance and the semantics table: [core attribute reference](../core/attribute-reference.md). ## Verify ```bash php yii indexnow/check # config, key file reachable, engines, queue component, cache component, spool php yii indexnow/check --live # also sends a real probe request to every engine ``` Run it after every key rotation and after every deployment that touches the configuration. ## How it works - URLs are resolved **in the ActiveRecord event**, while the old state is live (`changedAttributes` on `afterUpdate`, the row and its relations in `beforeDelete`). A renamed page announces its old URL as deleted. - Outside a transaction they go to the request collector right away. Inside one, Yii2 gives no savepoint events, so they are held with a verifier and **re-read by primary key when the transaction commits**: a change the row does not show (an inner `beginTransaction()` that rolled back) is dropped with every URL it produced. A rollback drops everything. One `SELECT` per changed record, only inside explicit transactions. Details: [docs/commit-safety.md](commit-safety.md). - Everything collected during one request is sent **after the response** (`Response::EVENT_AFTER_SEND`), in one batch; console commands flush when they end, queue workers after every job. - `dispatch: auto` (default) pushes a `SubmitUrlsJob` to the `queue` component when `yiisoft/yii2-queue` is configured (429/5xx re-pushed with the delay of `retry.*`, `Retry-After` honoured), else sends synchronously. Details: [docs/queue.md](queue.md). - Nothing thrown from a rule, a resolver or the HTTP layer reaches your application: it is logged under the `indexnow` category, the save succeeds. An invalid configuration disables IndexNow with one `critical` line; `php yii indexnow/check` prints the exact error. ## Commands | Command | Options | |---|---| | `indexnow/check` | `--live` real probe · `--host=` one host · `--probe-url=` page for the probe | | `indexnow/submit ` | `--force` ignore debounce · `--dry-run` · `--json` | | `indexnow/submit-record [ids...]` | `--event=` · `--limit=` · `--explain` · `--force` · `--dry-run` · `--json` | | `indexnow/explain ` | `--event=` — rules, `when`, URLs, key, debounce; sends nothing | | `indexnow/sitemap [sitemap]` | `--changed-since="1 day"` · `--allow-foreign-hosts` · `--force` · `--dry-run` · `--json` · `--no-verify` | | `indexnow/history` | `--host=` · `--status=ok|pending|failed|skipped` · `--url=` · `--since=2h|3d|2026-09-01` · `--limit=` (default 50) · `--json` · `--purge[=days]` | | `indexnow/status` | `--json` | | `indexnow/key-generate` | `--length` · `--alphanumeric` · `--write-env[=FILE]` · `--force` rotate | `` is an FQCN or a short name under `app\models`. Ids are space- or comma-separated. ### Sitemaps `composer require indexnowkit/sitemap # optional: the indexnow/sitemap command` `indexnow/sitemap` with no argument reads `sitemap.url`, else `/sitemap.xml`; a local path works too. Without the package everything else works unchanged: `indexnow/sitemap` says `indexnowkit/sitemap is not installed: composer require indexnowkit/sitemap` and exits 1, `indexnow/check` prints `sitemap: not installed (…)`, a `sitemap` block in the options is ignored, `sitemapConfig()` / `sitemapSource()` throw a `LogicException` with the same sentence. Nothing is logged about it. ### History `composer require indexnowkit/history # optional: what was submitted, when, with what answer` ```php 'indexnow' => ['class' => IndexNowComponent::class, 'options' => [ // ... 'history' => [ 'store' => 'pdo', // null (default, nothing kept) | psr16 (the debounce cache component) | pdo 'pdo' => ['service' => 'db'], // the db component holding the table — or 'dsn' => 'sqlite:/var/data/indexnow.sqlite' ], ]], ``` Every `Result` the submitter produces — the flush after the response, the yii2-queue job, the commands, a URL skipped by `indexnowkit/verify` — is recorded: normalized URLs, host, engine, status, reason, HTTP code, the error message (never the response body or the key). `php yii indexnow/history` lists them newest first (`--host`, `--status`, `--url`, `--since`, `--json`); `indexnow/history --purge` removes what is older than `history.retention_days` (a cron line); `php yii indexnow/status` prints the switches, the dispatch mode with the queue component, the debounce store, the 403 counter of every host, the last successful submission and the history size (`--json` for machines). `pdo` needs the table: the migration is in the package's [docs/migrations.md](../history/migrations.md) (`Schema::sql()`); until it exists `indexnow/check` prints a `history.store` error and the submitter logs the failure without breaking the flush. `psr16` is a ring buffer of `history.limit` records for one process and small sites. The `submissionStore` property of the component takes precedence over either. Without the package `indexnow/history` and `indexnow/status` say `indexnowkit/history is not installed: composer require indexnowkit/history` and exit 1, `indexnow/check` prints `history: not installed (…)`, `historyConfig()` throws a `LogicException` with the same sentence. ## Configuration and docs Every option, its default and what it does: [docs/configuration.md](configuration.md). Commit safety: [docs/commit-safety.md](commit-safety.md). Replacing pieces, custom resolvers, checks: [docs/extending.md](extending.md). Queue, retries, failures: [docs/queue.md](queue.md). Several hosts, www and apex, languages: [docs/multi-domain.md](multi-domain.md). Testing your integration: [docs/testing.md](testing.md). ## Operations - [Production checklist](../core/operations.md#production-checklist) — key and base URL, `check` in the deploy pipeline, `strict_hosts`, a shared debounce store, a monitored queue, staging that cannot submit, the three lines to alert on. - [Monitoring rules and the Sentry filter](../core/operations.md#monitoring-rules), [deleted pages](../core/operations.md#deleted-pages-what-your-site-must-return), [what not to submit](../core/operations.md#what-not-to-submit). - [Multi-domain: hosts, www and apex, languages](multi-domain.md) · [queue](queue.md) · [commit safety](commit-safety.md) · [troubleshooting](troubleshooting.md). ## Debugging `php yii indexnow/check` validates the options, fetches the key file and reports how submissions are wired (queue, cache, pretty URLs, ActiveRecord hooks, sitemap spool); `php yii indexnow/explain 'app\models\Post' 1` shows the rules, guards and URLs of one record without sending anything; the `indexnow` log category at `debug` tells why a URL was or was not submitted. Symptoms and fixes: [docs/troubleshooting.md](troubleshooting.md). ## Limitations - `updateAll()`, `deleteAll()`, `updateAttributes()`, `updateCounters()` fire no events (conformance A13): call `Yii::$app->indexnow->submitRecords(Post::find()->where(...)->all())` or `php yii indexnow/submit-record` afterwards. - `link()` / `unlink()` write the junction row with a plain command, no event on the owner: save the owner with a bumped timestamp afterwards (`$post->updated_at = time(); $post->save(false)`), or call `submitRecord($post)`. - The sync driver of `yii2-queue` ignores the delay between attempts: 429/5xx attempts run back-to-back (development only, `check` warns). - Without pretty URLs the key file cannot be routed: enable them, or serve `/.txt` as a static file and set `key_file.enabled: false`. ## Compatibility Public API: the `options` tree, command names and options, `IndexNowComponent` methods and properties, `ActiveRecord\IndexNowBehavior`, `Queue\SubmitUrlsJob`. The core's rules apply: [bc.md](../core/bc.md); what this package itself keeps stable: [docs/bc.md](bc.md). Before 1.0 a minor version may break; every break is listed under "Changed" in [CHANGELOG.md](https://github.com/indexnowkit/php/blob/main/packages/yii2/CHANGELOG.md). Yii 2.0.45+, PHP 8.2–8.5. ## Notes for AI assistants - Composer package `indexnowkit/yii2` (Yii 2.0.45+, on `indexnowkit/core`); the `indexnow/sitemap` command needs `indexnowkit/sitemap`; pre-flight checks need `indexnowkit/verify`; `indexnow/history` / `indexnow/status` need `indexnowkit/history` (`history.store: psr16|pdo`). Configuration: the `indexnow` application component (`options` array), `'bootstrap' => ['indexnow']`. `php yii indexnow/key-generate --write-env` writes a fresh `INDEXNOW_KEY`; `php yii indexnow/submit …` submits URLs by hand, `php yii indexnow/explain ` shows why a URL is or is not produced. - Minimal complete snippet (every `use` included): ```php use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults}; use IndexNowKit\Yii2\ActiveRecord\IndexNowBehavior; #[IndexNowDefaults(when: 'published', fields: ['slug', 'title', 'published'])] #[IndexNow(route: 'post/view', params: ['slug' => 'slug'])] #[IndexNow(urls: ['/'])] final class Post extends ActiveRecord { public function behaviors(): array { return [IndexNowBehavior::class]; } } ``` - Verify: `php yii indexnow/check` (exit 1 on any error; `--strict` fails on warnings too, `--json` for machines), `php yii indexnow/config --json` (the effective configuration, keys masked: paste it into a bug report), `php yii indexnow/explain 'app\\models\\Post' 1` (why a URL was or was not produced), `php yii indexnow/submit-record 'app\\models\\Post' 1 --dry-run`. - Pitfalls: - `dispatch: auto` exists in Symfony (`auto` | `messenger` | `sync` | `none`) and Yii2 (`auto` | `queue` | `sync` | `none`), **not** in Laravel (`queue` | `sync` | `none`); Yii3 has `sync` | `none` only. - Locales: `router.locales` in Laravel, Yii2 and Yii3, `framework.enabled_locales` in Symfony; `locales: 'all'` on a rule uses that list. - `url:` names an accessor (method or property) that returns the URL; `urls:` is a list of literal URLs. Never put a literal in `url:`. - A string in `when:` is an accessor read as truthy (`published`, `isPublished`). A status string needs `Equals`: `when: new Equals('status', 'published')` (`IndexNowKit\Attribute\Param\Equals`). - Manual submission is `submitEntity()` in Symfony, `submitModel()` in Laravel, `submitRecord()` in Yii2 and Yii3; the commands are `indexnow:submit-entity`, `indexnow:submit-model`, `indexnow/submit-record` (Yii2), `indexnow:submit-record` (Yii3). Bulk queries (`update()`, `DB::table()`, `updateAll()`) fire no hooks: submit afterwards with those. - Laravel has two classes called `IndexNowKit`: the facade `IndexNowKit\Laravel\Facades\IndexNowKit` and the core service `IndexNowKit\IndexNowKit` (inject by type). Yii2 exposes the core through `Yii::$app->indexnow->kit()`; Yii3 defines `IndexNowKit\IndexNowKit` in the container. - Outside production a configured key with `dry_run` unset makes `check` fail (a staging copy would submit real URLs): set `dry_run: true` there, or `dry_run: false` explicitly when it submits on purpose. - Unknown configuration keys are warned about at boot (typos such as debounce.per_urls); the key list is `Config::OPTIONS` plus the adapter's own keys. ## Other frameworks | | | |---|---| | PHP | [core](../core/index.md), [symfony-bundle](../symfony-bundle/index.md), [doctrine](../doctrine/index.md), [laravel](../laravel/index.md) | | JS/TS | @indexnowkit/core, next, prisma (soon) | | Python | indexnowkit, indexnowkit-django (soon) | MIT. IndexNow is a trademark of its owner; this project is independent and not affiliated with Microsoft, Yandex or indexnow.org. # Yii3 IndexNow package — `indexnowkit/yii3` Tell search engines about new, changed and deleted pages the moment an ActiveRecord row is committed. One attribute on the model, one `composer require`, done. [![Packagist](https://img.shields.io/packagist/v/indexnowkit/yii3)](https://packagist.org/packages/indexnowkit/yii3) [![Downloads](https://img.shields.io/packagist/dt/indexnowkit/yii3)](https://packagist.org/packages/indexnowkit/yii3) [![CI](https://github.com/indexnowkit/php/actions/workflows/ci.yml/badge.svg)](https://github.com/indexnowkit/php/actions) [![Conformance](https://img.shields.io/badge/conformance-core%2022%2F22%20%C2%B7%20orm%2021%2F21%20%C2%B7%20http%206%2F6-brightgreen)](https://github.com/indexnowkit/spec) ![PHPStan](https://img.shields.io/badge/phpstan-level%209-4c1) ![PHP](https://img.shields.io/badge/php-%5E8.2-777bb4) ![Yii](https://img.shields.io/badge/yiisoft%2Factive--record-%5E1.0-1a73e8) [![License](https://img.shields.io/packagist/l/indexnowkit/yii3)](https://github.com/indexnowkit/php/blob/main/packages/yii3/LICENSE) [Русская версия](https://github.com/indexnowkit/php/blob/main/packages/yii3/README.ru.md) · Issues and pull requests: [github.com/indexnowkit/php](https://github.com/indexnowkit/php/issues) (the `php-*` repositories are read-only splits) ## Who gets notified **Yandex, Bing (and DuckDuckGo via Bing), Naver, Seznam, Yep, Internet Archive, Amazon** — every engine in the [IndexNow](https://www.indexnow.org) [registry](https://www.indexnow.org/searchengines.json). One request to the shared endpoint reaches all of them; name engines explicitly only to reach a single one. **Google: no.** Google does not support IndexNow; this package will not pretend otherwise. **Notification, not indexing.** IndexNow tells an engine that a URL changed; whether and when the page is crawled and indexed is the engine's decision. See the result in Bing Webmaster Tools (IndexNow Insights) and Yandex.Webmaster (Indexing → Reindex pages); a useful metric is the share of submitted URLs in the index after a few days. Deleted pages: answer 410 (gone for good) or 404 (temporarily); for a move answer 301 and submit both URLs; a soft-404 or a redirect to the home page does harm. Bing's URL Submission API and Google's Indexing API are different protocols and not covered here. ## Why this over X Most IndexNow packages are a thin HTTP client: you collect the URLs, you call it, you read the answer. This family does the part that goes wrong in practice: - **Declared on the model** (`#[IndexNow]`) and submitted from the ActiveRecord events — no controller code to forget. - **After the commit**, not on save: a rolled-back transaction announces nothing. - **Debounce** (10 minutes per URL, shared through your cache), **batches** of up to 10 000 URLs, one key per host from env. - **Answers handled**: 202 (key pending), 422, 429 with `Retry-After` back-off, 403 escalation. - **`check` before the first submission** says what is wrong (key file, engines, cache, environment, the route, the hook); `explain` says why a URL was or was not sent. - **One core** under the Symfony, Laravel, Yii2, Yii3 and Doctrine adapters with a shared conformance suite: the same behaviour everywhere, documented once. ## Install ```bash composer require indexnowkit/yii3 symfony/http-client nyholm/psr7 # any PSR-18 client + PSR-17 factories work composer require indexnowkit/sitemap # optional: the indexnow:sitemap command ``` An application on `yiisoft/app` (or any application that loads the `yiisoft/config` groups `params`, `di`, `di-web`, `di-console`, `params-console`, `events-web`, `events-console`, `routes` and `bootstrap` through its runner) is wired by the package's `config/*.php`: the container definitions, the key file route, the console commands, the flush after the response and the ActiveRecord observer. Configure it in your params: ```php // config/common/params.php 'indexnowkit/yii3' => [ 'key' => $_ENV['INDEXNOW_KEY'] ?? null, // or leave it: the package reads INDEXNOW_KEY itself 'base_url' => 'https://www.example.com', // used by console commands (no request to take the host from) // dev/staging: log the request, send nothing. Compare with the same list `production_environments` holds, // or `YII_ENV=production` would switch dry-run on in production (check fails when this is unset outside it). 'dry_run' => !in_array($_ENV['YII_ENV'] ?? null, ['prod', 'production'], true), ], ``` ```bash ./yii indexnow:key:generate --write-env # writes INDEXNOW_KEY=… to .env (or prints the key) ./yii indexnow:check # params, key file reachable, route, hook, cache, dispatch ``` The package reads `INDEXNOW_KEY`, `INDEXNOW_PREVIOUS_KEY`, `INDEXNOW_BASE_URL` and `INDEXNOW_DRY_RUN` from `$_ENV`, `$_SERVER` and `getenv()` (`vlucas/phpdotenv` of the application template fills `$_ENV`), and takes the environment name from `YII_ENV`. The key file route needs `yiisoft/router` with `yiisoft/router-fastroute` (the application template has them); URL generation for `route:` rules goes through the container's `UrlGeneratorInterface`. The package needs a PSR-18 client (`symfony/http-client` + `nyholm/psr7` as above, or Guzzle): it discovers one, or takes the container id named in `http.client`. The `params` merge of your application must be recursive for the block (`RecursiveMerge::groups('params', ...)` in `configuration.php`, as the template does); without it the package's defaults still apply to what your block leaves out. ## Declare what has a public page `#[IndexNow]` is repeatable: one attribute per family of public URLs. `#[IndexNowEvents]` registers the hooks, and `EventsTrait` of yiisoft/active-record is what makes the record dispatch events at all. Save the example as `src/Model/Post.php` under `namespace App\Model;` — it reads the columns `slug`, `title`, `body`, `published`, `amp` (the AMP page exists while it is true) and `category_id`; `Category` is a record of your own with its own `#[IndexNow]` rule (drop the `via: 'category'` line if you have none). `route:` names a route of your `routes.php` (`Route::get('/posts/{slug}')->name('post/view')`). ```php use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults}; use IndexNowKit\Yii3\ActiveRecord\IndexNowEvents; use Yiisoft\ActiveRecord\ActiveQueryInterface; use Yiisoft\ActiveRecord\ActiveRecord; use Yiisoft\ActiveRecord\Trait\EventsTrait; use Yiisoft\ActiveRecord\Trait\MagicRelationsTrait; #[IndexNowDefaults(when: 'published', fields: ['slug', 'title', 'body', 'published'])] #[IndexNow(route: 'post/view', params: ['slug' => 'slug'])] #[IndexNow(route: 'post/amp', params: ['slug' => 'slug'], when: 'amp')] #[IndexNow(via: 'category')] // a changed post also refreshes its category page #[IndexNow(urls: ['/'])] // and the homepage #[IndexNowEvents] // the hook: yiisoft/active-record dispatches events only with EventsTrait final class Post extends ActiveRecord { use EventsTrait; use MagicRelationsTrait; public ?int $id = null; public string $slug = ''; public string $title = ''; public ?string $body = null; public bool $published = true; public bool $amp = false; public ?int $category_id = null; public function tableName(): string { return 'posts'; } public function getCategoryQuery(): ActiveQueryInterface { return $this->hasOne(Category::class, ['id' => 'category_id']); } } ``` | Option | Meaning | |---|---| | `route` / `params` | a route name and `argument => property, method, "self", dotted.path` (`self` = the primary key) | | `resolver` | a `UrlResolverInterface` class or container id for anything custom | | `via` | a relation (or dotted path) whose pages are resubmitted | | `url` / `urls` | a method returning the URL(s), or literal URLs | | `when` / `whenFields` | bool property or method; drafts are skipped and `published → draft` is sent as a deletion | | `fields` | for updates, submit only when one of these properties changed | | `events`, `locales`, `host`, `name` | subset of events; `current`/`all`/list (`router.locales`); another host; stable rule id | Accessors read ActiveRecord properties and relations (`category.slug`, a `getQuery()` relation) and fall back to methods. **`via:` and any dotted path through a relation need `MagicRelationsTrait`** next to `EventsTrait`, as in the model above: yiisoft/active-record reads a `getQuery()` relation by name only through that trait, and without it the rule fails with an error from the core instead of yielding the related pages. A `when` column that only has a **database** default is null on a fresh record until `loadDefaultValues()`: give the typed property a default (`public bool $published = true;`) as above. Classes you cannot annotate: `'active_record' => ['models' => [Product::class]]` in the params (the class still needs `EventsTrait`), or `$indexNow->observe(Product::class, [new IndexNow(...)])` at runtime. Full model, typed parameters, inheritance and the semantics table: [core attribute reference](../core/attribute-reference.md). ## Verify ```bash ./yii indexnow:check # params, key file reachable, engines, route, hook, cache, dispatch, spool ./yii indexnow:check --live # also sends a real probe request to every engine ``` Run it after every key rotation and after every deployment that touches the configuration. ## How it works - URLs are resolved **in the ActiveRecord event**, while the old state is live (`BeforeUpdate` keeps the old values for `AfterUpdate`, `BeforeDelete` still sees the row and its relations). A renamed page announces its old URL as deleted. - Outside a transaction they go to the request collector right away. Inside one, yiisoft/db gives no commit or rollback events at all, so they are held with a verifier and **re-read by primary key at the end of the request** (or the command): a change the row does not show (a rolled-back transaction, an inner `beginTransaction()` that rolled back to its savepoint) is dropped with every URL it produced. One `SELECT` per changed record, only inside explicit transactions. Details: [docs/commit-safety.md](commit-safety.md). - Everything collected during one request is sent **after the response** (`AfterEmit` of yiisoft/yii-http), in one batch; a console command flushes when it ends (`ApplicationShutdown`); a long-running command calls `$indexNow->flush()` between its units of work. - `dispatch: sync` (default) sends inline after the response; `none` collects and never sends. There is no queue mode until `yiisoft/queue` has a stable release: replace `DispatcherInterface` in your `di/` with a dispatcher over the queue you run ([docs/extending.md](extending.md)). - Nothing thrown from a rule, a resolver or the HTTP layer reaches your application: it is logged under the `indexnow` category (`logging.category`), the save succeeds. An invalid configuration disables IndexNow with one `critical` line; `./yii indexnow:check` prints the exact error. ## Commands | Command | Options | |---|---| | `indexnow:check` | `--live` real probe · `--host=` one host (repeatable) · `--probe-url=` page for the probe · `--json` · `--strict` · `--sample=` / `--sample-class=` (needs `indexnowkit/verify`) | | `indexnow:config` | `--json` — the effective configuration, keys and DSNs masked | | `indexnow:submit ` | `--force` ignore debounce · `--dry-run` · `--json` | | `indexnow:submit-record [ids...]` | `--event=` · `--limit=` · `--explain` · `--force` · `--dry-run` · `--json` | | `indexnow:explain ` | `--event=` · `--json` — rules, `when`, URLs, key, debounce; sends nothing | | `indexnow:sitemap [sitemap]` | `--changed-since="1 day"` · `--allow-foreign-hosts` · `--force` · `--dry-run` · `--json` · `--no-verify` | | `indexnow:history` | `--host=` · `--status=ok|pending|failed|skipped` · `--url=` · `--since=2h|3d|2026-09-01` · `--limit=` (default 50) · `--json` · `--purge[=days]` | | `indexnow:status` | `--json` | | `indexnow:key:generate` | `--length` · `--alphanumeric` · `--write-env[=FILE]` · `--force` rotate · `--no-previous` · `--yes` | `` is an FQCN or a short name under `active_record.namespaces` (`App\Model`, `App\Entity` by default). ### Sitemaps `composer require indexnowkit/sitemap # optional: the indexnow:sitemap command` `indexnow:sitemap` with no argument reads `sitemap.url`, else `/sitemap.xml`; a local path works too. Without the package everything else works unchanged: `indexnow:sitemap` says `indexnowkit/sitemap is not installed: composer require indexnowkit/sitemap` and exits 1, `indexnow:check` prints `sitemap: not installed (…)`, a `sitemap` block in the params is ignored, `sitemapConfig()` / `sitemapSource()` throw a `LogicException` with the same sentence. Nothing is logged about it. ### Verify `composer require indexnowkit/verify # optional: one GET before every submission` With `'verify' => ['enabled' => true]` every URL is fetched before it is submitted: `noindex`, `robots.txt`, a canonical pointing elsewhere, a redirect or an origin error skip it with a logged reason, and `indexnow:check --sample=` / `--sample-class=` report what an engine would see. With `dispatch: sync` the GETs run inside the web request after the response was sent; `check` warns about it. Without the package a `verify` block is ignored and `check` prints `verify: not installed (…)`. ### History `composer require indexnowkit/history # optional: what was submitted, when, with what answer` ```php 'indexnowkit/yii3' => [ // ... 'history' => [ 'store' => 'pdo', // null (default, nothing kept) | psr16 (the debounce cache) | pdo 'pdo' => ['service' => ConnectionInterface::class], // the yiisoft/db connection id holding the table — or 'dsn' => 'sqlite:/var/data/indexnow.sqlite' ], ], ``` Every `Result` the submitter produces — the flush after the response, the commands, a URL skipped by `indexnowkit/verify` — is recorded: normalized URLs, host, engine, status, reason, HTTP code, the error message (never the response body or the key). `./yii indexnow:history` lists them newest first (`--host`, `--status`, `--url`, `--since`, `--json`); `indexnow:history --purge` removes what is older than `history.retention_days` (a cron line); `./yii indexnow:status` prints the switches, the dispatch mode, the debounce store, the 403 counter of every host, the last successful submission and the history size (`--json` for machines). `pdo` needs the table: the migration is in the package's [docs/migrations.md](../history/migrations.md) (`Schema::sql()`); until it exists `indexnow:check` prints a `history.store` error and the submitter logs the failure without breaking the flush. `psr16` is a ring buffer of `history.limit` records for one process and small sites. A `SubmissionStoreInterface` definition of your own in `di/` takes precedence over either. Without the package `indexnow:history` and `indexnow:status` say `indexnowkit/history is not installed: composer require indexnowkit/history` and exit 1, `indexnow:check` prints `history: not installed (…)`, `historyConfig()` throws a `LogicException` with the same sentence. ## Configuration and docs Every option, its default and what it does: [docs/configuration.md](configuration.md). Commit safety: [docs/commit-safety.md](commit-safety.md). Replacing pieces in the container, custom resolvers, checks, a queue: [docs/extending.md](extending.md). Several hosts, www and apex, locales: [docs/multi-domain.md](multi-domain.md). Testing your integration: [docs/testing.md](testing.md). ## Operations - [Production checklist](../core/operations.md#production-checklist) — key and base URL, `check` in the deploy pipeline, `strict_hosts`, a shared debounce store, staging that cannot submit, the three lines to alert on. - [Monitoring rules and the Sentry filter](../core/operations.md#monitoring-rules), [deleted pages](../core/operations.md#deleted-pages-what-your-site-must-return), [what not to submit](../core/operations.md#what-not-to-submit). - [Multi-domain: hosts, www and apex, locales](multi-domain.md) · [commit safety](commit-safety.md) · [troubleshooting](troubleshooting.md). ## Debugging `./yii indexnow:check` validates the params, fetches the key file and reports how submissions are wired (dispatch, cache, the route, the ActiveRecord hook, sitemap spool); `./yii indexnow:explain 'App\Model\Post' 1` shows the rules, guards and URLs of one record without sending anything; the `indexnow` log category at `debug` tells why a URL was or was not submitted. Symptoms and fixes: [docs/troubleshooting.md](troubleshooting.md). ## Limitations - `updateAll()`, `deleteAll()`, `updateCounters()` fire no events (conformance A13): call `$indexNow->submitRecords(Post::query()->where(...)->all())` or `./yii indexnow:submit-record` afterwards. - `upsert()` does fire an event (`AfterUpsert`), but the data layer does not say whether the row was inserted or updated, so it is announced as an update: a rule limited to `events: [Created]` does not fire on an upsert. - `link()` / `unlink()` write the junction row with a plain command, no event on the owner: save the owner with a bumped timestamp afterwards (`$post->updated_at = time(); $post->save();`), or call `submitRecord($post)`. - A record without `EventsTrait` dispatches no events at all: the attribute alone hooks nothing. - A transaction still open when the request ends delivers nothing (the verifier would read uncommitted data): the package logs a warning naming the count; close the transaction. - No `dispatch: queue` until `yiisoft/queue` is released; a queue of your own replaces `DispatcherInterface`. ## Compatibility Public API: the `indexnowkit/yii3` params block, command names and options, the container definitions of `config/di.php`, `IndexNow` methods, `ActiveRecord\IndexNowEvents`. The core's rules apply: [bc.md](../core/bc.md); what this package itself keeps stable: [docs/bc.md](bc.md). Before 1.0 a minor version may break; every break is listed under "Changed" in [CHANGELOG.md](https://github.com/indexnowkit/php/blob/main/packages/yii3/CHANGELOG.md). `yiisoft/active-record ^1.0`, `yiisoft/db ^2.0`, `yiisoft/router ^4.0`, PHP 8.2–8.5. ## Notes for AI assistants - Composer package `indexnowkit/yii3` (Yii3: `yiisoft/active-record ^1.0`, `yiisoft/db ^2.0`, `yiisoft/router ^4.0`, on `indexnowkit/core`); the `indexnow:sitemap` command needs `indexnowkit/sitemap`; pre-flight checks need `indexnowkit/verify`; `indexnow:history` / `indexnow:status` need `indexnowkit/history` (`history.store: psr16|pdo`). Configuration: the `indexnowkit/yii3` params block; the package's `config/*.php` are picked up by `yiisoft/config` (di, routes, events, bootstrap, commands). `./yii indexnow:key:generate --write-env` writes a fresh `INDEXNOW_KEY`; `./yii indexnow:submit …` submits URLs by hand, `./yii indexnow:explain ` shows why a URL is or is not produced, `./yii indexnow:config --json` prints the effective configuration with the keys masked. - Minimal complete snippet (every `use` included): ```php use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults}; use IndexNowKit\Yii3\ActiveRecord\IndexNowEvents; use Yiisoft\ActiveRecord\ActiveRecord; use Yiisoft\ActiveRecord\Trait\EventsTrait; use Yiisoft\ActiveRecord\Trait\MagicRelationsTrait; #[IndexNowDefaults(when: 'published', fields: ['slug', 'title', 'published'])] #[IndexNow(route: 'post/view', params: ['slug' => 'slug'])] #[IndexNow(via: 'category')] // needs MagicRelationsTrait, like any dotted path through a relation #[IndexNow(urls: ['/'])] #[IndexNowEvents] final class Post extends ActiveRecord { use EventsTrait; use MagicRelationsTrait; public ?int $id = null; public string $slug = ''; public string $title = ''; public bool $published = true; public ?int $category_id = null; public function tableName(): string { return 'posts'; } public function getCategoryQuery(): \Yiisoft\ActiveRecord\ActiveQueryInterface { return $this->hasOne(Category::class, ['id' => 'category_id']); } } ``` - Verify: `./yii indexnow:check` (exit 1 on any error; `--strict` fails on warnings too, `--json` for machines), `./yii indexnow:config --json` (paste it into a bug report), `./yii indexnow:explain 'App\\Model\\Post' 1` (why a URL was or was not produced), `./yii indexnow:submit-record 'App\\Model\\Post' 1 --dry-run`. - Pitfalls: - The record needs **both** `#[IndexNowEvents]` and `use EventsTrait;`: yiisoft/active-record dispatches events only through the trait, and the attribute only provides the handlers. `via:` and dotted paths through a relation additionally need `use MagicRelationsTrait;`. - `upsert()` raises `AfterUpsert`, not `AfterInsert`/`AfterUpdate`: it is announced as an update, so a rule limited to `events: [Created]` does not fire on it. `updateAll()`, `deleteAll()` and `updateCounters()` fire no events at all. - `dispatch` is `sync` or `none` in Yii3 (no queue until `yiisoft/queue` is stable; replace `DispatcherInterface` in `di/` for one); `dispatch: auto` exists in Symfony (`auto` | `messenger` | `sync` | `none`) and Yii2 (`auto` | `queue` | `sync` | `none`), **not** in Laravel (`queue` | `sync` | `none`). - Locales: `router.locales` in Laravel, Yii2 and Yii3 (`router.locale_parameter` names the route argument, `_language` in Yii3), `framework.enabled_locales` in Symfony; `locales: 'all'` on a rule uses that list. - `route:` is the **name** of a route (`->name('post/view')`), not its pattern; `route: 'post/view'` needs `Route::get('/posts/{slug}')->name('post/view')` in the routes configuration. - `url:` names an accessor (method or property) that returns the URL; `urls:` is a list of literal URLs. Never put a literal in `url:`. - A string in `when:` is an accessor read as truthy (`published`, `isPublished`). A status string needs `Equals`: `when: new Equals('status', 'published')` (`IndexNowKit\Attribute\Param\Equals`). - Manual submission is `submitEntity()` in Symfony, `submitModel()` in Laravel, `submitRecord()` in Yii2 and Yii3 (inject `IndexNowKit\Yii3\IndexNow`); the commands are `indexnow:submit-entity`, `indexnow:submit-model`, `indexnow/submit-record` (Yii2), `indexnow:submit-record` (Yii3). Bulk queries (`update()`, `DB::table()`, `updateAll()`) fire no hooks: submit afterwards with those. - Laravel has two classes called `IndexNowKit`: the facade `IndexNowKit\Laravel\Facades\IndexNowKit` and the core service `IndexNowKit\IndexNowKit` (inject by type). Yii2 exposes the core through `Yii::$app->indexnow->kit()`; Yii3 defines `IndexNowKit\IndexNowKit` and every core interface in the container. - Outside production a configured key with `dry_run` unset makes `check` fail (a staging copy would submit real URLs): set `dry_run: true` there, or `dry_run: false` explicitly when it submits on purpose. - Unknown configuration keys are warned about at boot (typos such as debounce.per_urls); the key list is `Config::OPTIONS` plus the adapter's own keys. ## Other frameworks | | | |---|---| | PHP | [core](../core/index.md), [symfony-bundle](../symfony-bundle/index.md), [doctrine](../doctrine/index.md), [laravel](../laravel/index.md), [yii2](../yii2/index.md) | | JS/TS | @indexnowkit/core, next, prisma (soon) | | Python | indexnowkit, indexnowkit-django (soon) | MIT. IndexNow is a trademark of its owner; this project is independent and not affiliated with Microsoft, Yandex or indexnow.org. # IndexNow command line — `indexnowkit/cli` Tell Yandex, Bing, Naver and Seznam which URLs changed, from any host that has PHP and from any CI — no framework. One binary, `indexnow`: check the key file, submit URLs or a whole sitemap (only what is new or changed since the last run), keep the history. Cron on Bitrix, WordPress, MODX, OpenCart or Joomla; a deploy of Hugo, Astro or Jekyll; "just send these ten URLs". Three packagings of the same thing: a Composer package, `indexnow.phar`, the Docker image `ghcr.io/indexnowkit/indexnow` — and a GitHub Action on top of the image. [![Packagist](https://img.shields.io/packagist/v/indexnowkit/cli)](https://packagist.org/packages/indexnowkit/cli) [![Downloads](https://img.shields.io/packagist/dt/indexnowkit/cli)](https://packagist.org/packages/indexnowkit/cli) [![CI](https://github.com/indexnowkit/php/actions/workflows/ci.yml/badge.svg)](https://github.com/indexnowkit/php/actions) ![PHPStan](https://img.shields.io/badge/phpstan-level%209-4c1) ![PHP](https://img.shields.io/badge/php-%5E8.2-777bb4) [![License](https://img.shields.io/packagist/l/indexnowkit/cli)](https://github.com/indexnowkit/php/blob/main/packages/cli/LICENSE) [Русская версия](https://github.com/indexnowkit/php/blob/main/packages/cli/README.ru.md) · Issues and pull requests: [github.com/indexnowkit/php](https://github.com/indexnowkit/php/issues) (the `php-*` repositories are read-only splits) ## Who gets notified **Yandex, Bing (and DuckDuckGo via Bing), Naver, Seznam, Yep, Internet Archive, Amazon** — every engine in the [IndexNow](https://www.indexnow.org) [registry](https://www.indexnow.org/searchengines.json). One request to the shared endpoint reaches all of them; name engines explicitly (`INDEXNOW_ENGINES=yandex,bing`) only to reach a single one. **Google: no.** Google does not support IndexNow; this tool will not pretend otherwise. IndexNow is a notification, not indexing: the engine decides whether and when to crawl. ## Install ```bash composer global require indexnowkit/cli # ~/.composer/vendor/bin/indexnow, or vendor/bin/indexnow in a project curl -LO https://github.com/indexnowkit/php-cli/releases/latest/download/indexnow.phar && php indexnow.phar --version docker run --rm ghcr.io/indexnowkit/indexnow --version ``` PHP 8.2+ with `pdo_sqlite` and `xmlreader` (the PHAR checks that before it runs; shared hosting without `pdo_sqlite` cannot keep the state file — `--state memory` runs without one). No `ext-intl` needed. ## Quick start ```bash cd /var/www/site # the .env and the state file live in the working directory indexnow key:generate --write-env # INDEXNOW_KEY=… into .env (mode 0600) echo 'INDEXNOW_BASE_URL=https://www.example.com' >> .env indexnow key:file /var/www/site/public # writes public/.txt: the file the engines verify indexnow check --live # the configuration, the key file over HTTP, one real probe per engine indexnow sitemap # the whole sitemap once… ``` …then one line in crontab — only what changed since the last run, whatever `` says: ```cron */30 * * * * cd /var/www/site && indexnow sitemap --new-only --json >> /var/log/indexnow.log 2>&1 ``` Or ten URLs by hand: `indexnow submit https://www.example.com/a /b /c`. ## Any CMS: Bitrix, WordPress, MODX, OpenCart, Joomla The CLI needs two things every site has: the document root (for `key:file`) and a sitemap. Bitrix generates `sitemap.xml` and the index per information block with its own "Search engine optimization" module (since version 14); WordPress has one at `/wp-sitemap.xml` (5.5+) or from an SEO plugin; MODX (`pdoTools`, `SEO Suite`), OpenCart (a sitemap feed) and Joomla (an extension) likewise. Point `INDEXNOW_SITEMAP_URL` at it when it is not `/sitemap.xml`, or give the file: `indexnow sitemap /var/www/site/public/sitemap.xml` reads it from disk. No PHP of the CMS runs; the CLI is a separate process on the same host. ### Bitrix, step by step On a BitrixVM the site lives in `/home/bitrix/www` (the other sites of a multi-site setup in `/home/bitrix/ext_www/`) and the "Search engine optimization" module writes `sitemap.xml` right into that document root (Marketing → Search engine optimization → sitemap.xml settings; regenerate it there or by its agent). The CLI needs PHP 8.2+ on the command line — the PHP the VM menu selected. ```bash mkdir -p /home/bitrix/indexnow && cd /home/bitrix/indexnow # outside the document root: the .env and the state file indexnow key:generate --write-env echo 'INDEXNOW_BASE_URL=https://www.example.com' >> .env indexnow key:file /home/bitrix/www # /home/bitrix/www/.txt, one file per configured host indexnow check --live ``` ```cron */30 * * * * cd /home/bitrix/indexnow && indexnow sitemap /home/bitrix/www/sitemap.xml --new-only --json >> /var/log/indexnow.log 2>&1 ``` The sitemap is read from disk, so nothing depends on the web server or on caching; only URLs the state file has not seen with this `` go out. Nothing is installed into Bitrix, no module, no agent: the CLI is a process of the `bitrix` user next to the site. ## Static sites: on deploy The GitHub Action `indexnowkit/indexnow-action` runs `check` and then `sitemap --new-only` from the image; the state file lives in `.indexnow/` and is cached between runs: ```yaml - uses: actions/cache@v4 with: { path: .indexnow, key: indexnow-${{ github.ref_name }} } - uses: indexnowkit/indexnow-action@v1 with: key: ${{ secrets.INDEXNOW_KEY }} base-url: https://www.example.com sitemap: dist/sitemap.xml # the file the build wrote, or a URL; default /sitemap.xml new-only: 'true' ``` Inputs, outputs and the step summary: [docs/action.md](action.md). Any other CI runs the image the same way: `docker run --rm -v "$PWD:/work" -e INDEXNOW_KEY -e INDEXNOW_BASE_URL ghcr.io/indexnowkit/indexnow sitemap --new-only` ([docs/docker.md](docker.md)). ## The state file `.indexnow/state.sqlite` in the working directory (`--state`, `INDEXNOW_STATE`), one sqlite file, created on first use in a `0700` directory: the debounce window (`debounce.per_url`, 10 minutes: a URL announced twice within it is sent once — `--force` overrides), the 403 counters per host, the submission history (`history`, `status`) and the fingerprints of the sitemap URLs announced so far (`sitemap --new-only`). Everything that makes a scheduled run idempotent, in one file that fits `actions/cache`; no backup needed (losing it costs one full re-announcement). `--state memory` keeps nothing between runs — a read-only container, a one-off `--dry-run`. `check` prints the `state:` line; details in [docs/state.md](state.md). ## Configuration Environment first, file second. Every option of the core and of the three packages is one variable: `INDEXNOW_` for the core (`INDEXNOW_KEY`, `INDEXNOW_BASE_URL`, `INDEXNOW_ENGINES`, `INDEXNOW_DEBOUNCE_PER_URL`, …), `INDEXNOW__` for the blocks (`INDEXNOW_SITEMAP_URL`, `INDEXNOW_SITEMAP_MAX_DEPTH`, `INDEXNOW_VERIFY_ENABLED`, `INDEXNOW_HISTORY_PDO_DSN`). A `.env` in the working directory is read when it is there (`--env-file`, `--no-env-file`); the process environment wins over it. For the parts that are not one value (a hosts map with per-host key locations) a JSON file in the shape of `Config::fromArray()`: `--config indexnow.json` or `INDEXNOW_CONFIG`; the variables win over the file, `INDEXNOW_HOSTS` replaces its hosts map whole. Precedence: the command's options, the process environment, `.env`, `--config`, the defaults. Two defaults are the CLI's: `debounce.store` is `state` (the state file; `memory` and `none` as everywhere) and `history.store` is `pdo` over the same file (`INDEXNOW_HISTORY_STORE=none` switches it off). `dispatch` is `sync` or `none`: a process has no queue. `check` warns about an `INDEXNOW_*` variable nothing reads. The tables: [docs/configuration.md](configuration.md). ## Commands | Command | | |---|---| | `check [--live] [--host=…] [--json] [--strict] [--sample=]` | the configuration, the key file of every host over HTTP, the state file, the debounce store, sitemap spool, verify, history; `--live` sends one probe per engine; `--sample` fetches a page the way the pre-flight would ([console](../console/index.md), codes in the core's `docs/check-codes.md`) | | `config [--json]` | the effective configuration, keys masked, plus the `cli` block (which file, which state); what a bug report pastes | | `submit … [--force] [--dry-run] [--json]` | URLs, or paths under `base_url`, now | | `sitemap [url|file] [--new-only] [--changed-since=…] [--dry-run] [--no-verify] [--json]` | the sitemap (index, gzip, text) in batches; `--new-only` only what the state file has not seen with this `lastmod` ([sitemap](../sitemap/index.md)) | | `key:generate [--write-env[=file]] [--force]` | a key; `--write-env` puts `INDEXNOW_KEY=` into `.env` (mode 0600; `--force` rotates, keeping the old key as `INDEXNOW_PREVIOUS_KEY`) | | `key:file [--host=…] [--dry-run]` | `/.txt` for every configured host (and the previous key during a rotation), or the path `key_location` names; the one command that exists only here | | `history [--host=…] [--status=…] [--since=…] [--json] [--purge]` | what was sent, when, with what answer ([history](../history/index.md)) | | `status [--json]` | switches, debounce store, 403 counters per host, the last successful submission, the history size | Global options: `--env-file`, `--no-env-file`, `--config`, `--state`, `-v` (the log on stderr). Exit codes: 0, 1 (an engine or the source failed), 2 (bad arguments; `--new-only` without a state, `sitemap.enabled: false`). `--json` keeps stdout machine-readable; the notes go to stderr. ## Docker `ghcr.io/indexnowkit/indexnow:` (`0.1`, `latest`), `php:8.3-cli-alpine` plus the PHAR, runs as the user `indexnow` (uid 1000) in `/work`: mount the directory that holds `.indexnow/` and the sitemap files there. The `-action` tag is the same image without a user for the GitHub Action. [docs/docker.md](docker.md). ## Limitations - No `explain`, no `submit-`: they need the ORM of a framework adapter. `check --sample ` says what an engine would see of a page (`indexnowkit/verify` is built in; `verify.enabled` switches the pre-flight on). - `debounce.store` and `history.pdo.service` cannot name a container id: there is no container. `state`, `memory`, `none`; `history.pdo.dsn` for a database of your own. - The image has no `ext-intl`: international host names go through the core's pure PHP Punycode. - Bulk changes in a CMS fire nothing: run `sitemap --new-only` on a schedule, that is the point. ## Other packages | Package | Framework | |---|---| | [`indexnowkit/symfony-bundle`](../symfony-bundle/index.md) | Symfony + Doctrine | | [`indexnowkit/laravel`](../laravel/index.md) | Laravel | | [`indexnowkit/yii2`](../yii2/index.md), [`indexnowkit/yii3`](../yii3/index.md) | Yii | | [`indexnowkit/core`](../core/index.md) | plain PHP, the library under all of them | A framework adapter submits the moment a model is committed; this CLI submits when it is run. On a host with a framework, prefer the adapter and use the CLI for what it adds (`key:file` on a bare host, a sitemap in cron). ## Notes for AI assistants - Composer package `indexnowkit/cli`, binary `indexnow` (also indexnow.phar from the GitHub releases of `indexnowkit/php-cli` and the image `ghcr.io/indexnowkit/indexnow`); no framework, no ORM. Configuration: `INDEXNOW_*` variables (`.env` in the working directory) or `--config FILE` (JSON); state in the `.indexnow` directory (state.sqlite). - Minimal complete setup (no PHP code: the CLI is the program): ```bash indexnow key:generate --write-env && echo 'INDEXNOW_BASE_URL=https://www.example.com' >> .env indexnow key:file /var/www/site/public && indexnow check --live indexnow sitemap --new-only --json # cron ``` ```php // the same graph in PHP, when the CLI is embedded (IndexNowKit\Cli\Wiring is a composition root over the core) use IndexNowKit\Cli\Application; exit((new Application())->run()); ``` - Commands: `check`, `config`, `submit`, `sitemap`, `key:generate`, `key:file`, `history`, `status` — the family's `indexnow:check`, `indexnow:config`, `indexnow:submit`, `indexnow:sitemap`, `indexnow:key:generate`, `indexnow:history`, `indexnow:status` without the prefix (the binary is the prefix). Verify: `indexnow check --live`. - Pitfalls: - The key file must be served by the site (`key:file ` writes it; `check` fetches it); a 403 from every engine means the key file is wrong or cached stale. - Outside production (`INDEXNOW_ENV` not in `prod, production`) a configured key with `dry_run` unset makes `check` fail; set `INDEXNOW_DRY_RUN=true` there or `INDEXNOW_ENV=prod`. - `sitemap --new-only` needs the state file between runs (`actions/cache` in CI); `--state memory` makes every run the first. `--changed-since` and `--new-only` add up. - `debounce.store` accepts `state`, `memory`, `none` only; `http.client` cannot be set (no container). `history.store` is `pdo` over the state file by default; `INDEXNOW_HISTORY_STORE=none` switches the history off. - Unknown `INDEXNOW_*` variables are warned about by `check` (code config.unknown); the key list is `Config::OPTIONS` plus `sitemap.*`, `verify.*`, `history.*` as `INDEXNOW__`. ## Versioning SemVer; until 1.0 minor versions may contain breaking changes, listed in [CHANGELOG.md](https://github.com/indexnowkit/php/blob/main/packages/cli/CHANGELOG.md). What the compatibility promise covers — the commands, their options and the variables, not the classes: [docs/bc.md](bc.md). MIT. IndexNow is a trademark of its owner; this project is independent and not affiliated with Microsoft, Yandex or indexnow.org. # IndexNow client for PHP — `indexnowkit/core` Tell Yandex, Bing and the other [IndexNow](https://www.indexnow.org) engines which URLs changed, from any PHP application. Batching, debounce, throttling, retry policy, key file handling and the `#[IndexNow]` rule model, on top of PSR-18 / PSR-17 / PSR-3 / PSR-16 only. The framework adapters ([Symfony](../symfony-bundle/index.md), [Doctrine](../doctrine/index.md), [Laravel](../laravel/index.md), [Yii2](../yii2/index.md), [Yii3](../yii3/index.md)) and the add-on packages build on it; use it directly in plain PHP, a CMS plugin or a custom framework. [![Packagist](https://img.shields.io/packagist/v/indexnowkit/core)](https://packagist.org/packages/indexnowkit/core) [![Downloads](https://img.shields.io/packagist/dt/indexnowkit/core)](https://packagist.org/packages/indexnowkit/core) [![CI](https://github.com/indexnowkit/php/actions/workflows/ci.yml/badge.svg)](https://github.com/indexnowkit/php/actions) [![Conformance](https://img.shields.io/badge/conformance-core%2022%2F22-brightgreen)](https://github.com/indexnowkit/spec) ![Coverage](https://img.shields.io/badge/coverage-%E2%89%A5%2081%25%20enforced-brightgreen) ![PHPStan](https://img.shields.io/badge/phpstan-level%209-4c1) ![PHP](https://img.shields.io/badge/php-%5E8.2-777bb4) [![License](https://img.shields.io/packagist/l/indexnowkit/core)](https://github.com/indexnowkit/php/blob/main/packages/core/LICENSE) [Русская версия](https://github.com/indexnowkit/php/blob/main/packages/core/README.ru.md) · Issues and pull requests: [github.com/indexnowkit/php](https://github.com/indexnowkit/php/issues) (the `php-*` repositories are read-only splits) ## Who gets notified **Yandex, Bing (and DuckDuckGo via Bing), Naver, Seznam, Yep, Internet Archive, Amazon** — every engine in the IndexNow [registry](https://www.indexnow.org/searchengines.json). One request to the shared endpoint `api.indexnow.org` reaches all of them; name engines explicitly (`engines: [yandex, bing]`) only to reach a single one. Internet Archive has no working direct endpoint at the time of writing — it is reached through `api`. **Google: no.** Google does not support IndexNow, its sitemap ping endpoint is gone and the Indexing API is limited to `JobPosting` / `BroadcastEvent`. Keep your sitemap for Google; this library will not pretend otherwise. **Notification, not indexing.** IndexNow tells an engine that a URL changed; whether and when the page is crawled and indexed is the engine's decision. See the result in Bing Webmaster Tools (IndexNow Insights) and Yandex.Webmaster (Indexing → Reindex pages); a useful metric is the share of submitted URLs in the index after a few days. Deleted pages: answer 410 (gone for good) or 404 (temporarily); for a move answer 301 and submit both URLs; a soft-404 or a redirect to the home page does harm. Bing's URL Submission API and Google's Indexing API are different protocols and not covered here. ## Why this over X Most IndexNow packages are a thin HTTP client: you collect the URLs, you call it, you read the answer. This family does the part that goes wrong in practice: - **Declared on the model** (`#[IndexNow]`) and submitted from the ORM hooks — no controller code to forget. - **After the commit**, not on flush: a rolled-back transaction announces nothing. - **Debounce** (10 minutes per URL, shared through your cache), **batches** of up to 10 000 URLs, one key per host from env. - **Answers handled**: 202 (key pending), 422, 429 with `Retry-After` back-off and a retry through your queue, 403 escalation. - **`check` before the first submission** says what is wrong (key file, engines, queue, cache, environment); `explain` says why a URL was or was not sent. - **One core** under the Symfony, Laravel, Yii2, Yii3 and Doctrine adapters with a shared conformance suite: the same behaviour everywhere, documented once. ## Install ```bash composer require indexnowkit/core symfony/http-client nyholm/psr7 # any PSR-18 client + PSR-17 factories work ``` If you use a framework, prefer its adapter: it wires everything below through your container and hooks into entity changes. The family: | Package | What | |---|---| | `indexnowkit/core` | this package: protocol client, rules, key file, the adapter kit | | [`indexnowkit/doctrine`](../doctrine/index.md) | Doctrine ORM listener plus a DBAL middleware, commit-safe | | [`indexnowkit/symfony-bundle`](../symfony-bundle/index.md) | Symfony: config, Messenger, key file route, commands, profiler panel | | [`indexnowkit/laravel`](../laravel/index.md) | Laravel: Eloquent observer, queue, key file route, artisan commands | | [`indexnowkit/yii2`](../yii2/index.md) | Yii2: ActiveRecord events with verify-on-commit, yii2-queue, console controller | | [`indexnowkit/yii3`](../yii3/index.md) | Yii3: `#[IndexNowEvents]` on yiisoft/active-record with verify-on-commit, a yiisoft/config plugin, console commands | | [`indexnowkit/sitemap`](../sitemap/index.md) | reads a sitemap (index, gzip, text) and submits its URLs; the `sitemap` command of every adapter | | [`indexnowkit/verify`](../verify/index.md) | one GET before every submission: noindex, robots.txt, canonical, redirects, origin errors; `check --sample` | | [`indexnowkit/history`](../history/index.md) | what was submitted, when, with what answer: PSR-16 and PDO stores, the `history` and `status` commands | | [`indexnowkit/console`](../console/index.md) | the `check`, `config`, `submit`, `submit-`, `explain`, `key:generate` commands (`symfony/console` classes, their bodies and their definitions); every adapter requires it | | [`indexnowkit/cli`](../cli/index.md) | no framework: the `indexnow` binary (Composer, PHAR, Docker image, GitHub Action) — `check`, `submit`, `sitemap --new-only`, `key:file`, `history`, `status` over `INDEXNOW_*` variables and a state file; cron on any CMS (Bitrix, WordPress, MODX, OpenCart), static sites on deploy | | [`indexnowkit/testing`](../testing/index.md) | `require-dev`: the conformance kits (C01–C22, A01–A21), the H01–H06 assertions, the mock IndexNow server | ## Quick start ```php use IndexNowKit\Config; use IndexNowKit\IndexNowKit; $indexNow = IndexNowKit::create(Config::fromEnv()); // INDEXNOW_KEY, INDEXNOW_BASE_URL, ... foreach ($indexNow->submit(['/posts/hello', 'https://www.example.com/about']) as $result) { printf("%s %s %d %s\n", $result->engine, $result->status->value, $result->httpCode ?? 0, $result->error ?? ''); } ``` ```dotenv INDEXNOW_KEY=6f3c9a... # 8-128 characters, [A-Za-z0-9-] INDEXNOW_BASE_URL=https://www.example.com ``` `submit()` never throws for remote problems: every engine × host × batch yields a `Result` and a log line, and URLs that were not sent (debounced, disabled, dry-run, unknown host) yield a `skipped` result that says why. ## The key file Search engines verify ownership by fetching `https://{host}/{key}.txt`, whose body must be exactly the key. ```php $key = IndexNowKit\Key\KeyGenerator::generate(); // 32 hex characters, CSPRNG file_put_contents("public/$key.txt", $key); // or answer the request yourself: $body = (new KeyFileResponder($indexNow->keys))->bodyForPath($path, $host); // null -> 404 ``` Serve it with `200 OK` and `text/plain`, without redirects; `KeyFileResponder::headers()` has the right headers. A key file elsewhere on the host is fine with `key_location`. `Check\Checker` validates the configuration, fetches every key file and, with `liveProbe: true`, sends a real probe. `403` always means the key file is wrong; rotation guidance is in [docs/operations.md](operations.md). ## What happens to a URL 1. **Normalize** — relative paths resolved against `base_url`, scheme and host lower-cased, IDN hosts to punycode, default ports and fragments removed, dot-segments resolved. Anything that is not a public `http(s)` URL is dropped with a warning. 2. **De-duplicate** within the call, then **debounce**: URLs sent successfully in the last `debounce.per_url` seconds are skipped. A failing store never blocks delivery, it just stops de-duplicating and logs a warning. 3. **Group by host** and look up the key. Hosts without a key are `skipped` and never sent under another host's key. 4. **Chunk** into at most `batch.max_urls` URLs, **throttle** one token per HTTP request, and **POST** one batch per endpoint: `{"host", "key", "keyLocation"?, "urlList"}` as `application/json; charset=utf-8`. 5. **Interpret** the answer into a `Result` and mark successful URLs in the debounce store. ## Results | `status` | HTTP | `reason` | `retryable` | Meaning | |---|---|---|---|---| | `ok` | 200 | — | no | accepted | | `pending` | 202 | — | no | accepted, key verification pending; counts as success | | `failed` | 400 | `invalid_request` | no | malformed request (bug: please report) | | `failed` | 403 | `invalid_key` | no | key file not reachable or does not match | | `failed` | 422 | `unprocessable` | no | URLs do not belong to the host / `keyLocation` invalid | | `failed` | 429 | `rate_limited` | yes | `retryAfter` filled when the engine said so | | `failed` | 5xx | `server_error` | yes | | | `failed` | — | `transport` | yes | network failure or timeout | | `failed` | — or other | `unexpected` | see below | a misbehaving HTTP client (retryable) or a status no engine should return (not) | | `skipped` | — | `disabled` `dry_run` `debounced` `no_key` `invalid_url` | no | nothing was sent | `Reason` is the stable identifier for metrics and alerts, `Result::$error` the human sentence; `Reason::translationKey()` (`indexnowkit.reason.`) names the message for a UI. Decide whether to retry from `Result::$retryable`, not from the reason. `Result` also carries `engine`, `endpoint`, `host`, `urls`, `httpCode` and `metricLabels()`; `Result::retryableUrls($results)` collects what is worth retrying. ```php $indexNow->submitter->addListener(fn (IndexNowKit\Result $r) => $metrics->increment('indexnow_results_total', $r->metricLabels())); ``` Log lines go to the PSR-3 logger you pass to `IndexNowKit::create()`. See [docs/operations.md](operations.md) for the levels, the exact messages and a "my URL was not submitted" checklist. ## Declaring pages: `#[IndexNow]` `#[IndexNow]` is **repeatable**: one attribute per family of public URLs the object has. Exactly one source per rule — `route`, `resolver`, `via`, `url` or `urls`. Class-wide policy goes to `#[IndexNowDefaults]`, whose `when` is ANDed with each rule's own `when` (a draft page is never public, whatever the rule says). ```php use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults, IndexNowUrl}; use IndexNowKit\Attribute\Param\{Accessor, Call, Formatted, Placeholder, Value}; #[IndexNowDefaults(when: 'isPublished', fields: ['slug', 'title', 'body', 'published'])] #[IndexNow(route: 'post_show', params: ['slug' => 'slug'])] // the article page #[IndexNow(route: 'post_amp', params: ['slug' => 'slug'], when: 'hasAmp', whenFields: ['ampEnabled'])] #[IndexNow(via: 'category')] // resubmit the category page #[IndexNow(via: 'tags')] // and every tag page #[IndexNow(urls: ['/', '/blog'])] // and two literal URLs class Post {} ``` Typed parameter sources, next to the plain accessor string (property, getter, `is`/`has` method, `dotted.path`, `self`): ```php #[IndexNow(route: 'post_show', params: [ 'year' => new Formatted('publishedAt', 'Y'), // DateTimeInterface::format() 'cat' => 'category.slug', // dotted path through a relation 'section' => new Value('blog'), // a constant 'slug' => new Call('slugFor', Placeholder::Locale), // a method call, one URL per locale ])] ``` Other shapes, all real cases: ```php #[IndexNow(url: 'publicUrl')] // a property or method returning string|iterable|null #[IndexNow(resolver: SyliusChannelUrls::class)] // a UrlResolverInterface class or service id #[IndexNow(route: 'page_show', params: ['slug' => 'slug'], host: new Accessor('tenant.domain'))] // multi-domain #[IndexNow(route: 'post_show', params: ['slug' => 'slug'], locales: 'all')] // localized routes class Page {} class Offer { #[IndexNowUrl(when: 'isLive')] // the get_absolute_url() convention public function getPublicUrl(): string { return '/offers/' . $this->code; } } ``` Rules are inherited from parent classes and identified by `name` (derived from the source, or given explicitly): a subclass rule whose name repeats an ancestor's **replaces** it, a new name **adds** a page. ### Deletion semantics Visibility (`when`) is evaluated per rule, before and after a change. `true → false` submits that rule's URLs as a **deletion** so engines recrawl the 404; `false → true` is a creation; no transition is an update filtered by `fields`. Deleting an object whose rule does not apply submits nothing: the page was never public. `when` is often a getter (`isPublished`) while the ORM change set holds the field (`published`). The convention `isPublished → published`/`is_published` and `getStatus → status` is applied automatically; when the names are unrelated, name the backing fields with `whenFields`. A status string or enum is not a boolean: use `when: new Equals('status', 'published')` (`IndexNowKit\Attribute\Param\Equals`); rules registered at runtime may pass a closure. Full model, semantics table and the adapter-facing types (`UrlRule`, `RuleSet`, `RuleRegistry`): [docs/attribute-reference.md](attribute-reference.md). ```php $indexNow = IndexNowKit::create($config, resolver: new AttributeUrlResolver(new AttributeReader(), ParamExtractor::plain(), $router, $locator)); $indexNow->submitEntity($post, IndexNowKit\Event::Updated); $indexNow->submitEntities($posts); // many objects, de-duplicated, one request per host and batch $urls = $indexNow->urlsFor($post, Event::Deleted); // resolve without sending $rows = $indexNow->explain($post, Event::Updated); // ResolvedUrl: which rule produced which URL ``` `urlsFor()`, `explain()` and `submitEntity()` go through `GuardedUrlResolver`, which never throws: an invalid attribute is logged and yields no URLs, so a typo cannot break a flush. ## Configuration | Option | Env | Default | Meaning | |---|---|---|---| | `enabled` | `INDEXNOW_ENABLED` | `true` | `false` drops every submission (logged at `info`) | | `key` | `INDEXNOW_KEY` | — | default key, used for every host not listed in `hosts` | | `hosts` | `INDEXNOW_HOSTS` (`a.com=KEY1,b.com=KEY2`) | `[]` | per-host `{key, key_location, base_url}` | | `strict_hosts` | `INDEXNOW_STRICT_HOSTS` | `false` | apply the default key only to the `base_url` host | | `base_url` | `INDEXNOW_BASE_URL` | `null` | resolves relative URLs; required outside HTTP requests | | `engines` | `INDEXNOW_ENGINES` | `['api']` | engine names or custom `https://` endpoints | | `dispatch` | `INDEXNOW_DISPATCH` | `sync` | adapter-defined delivery mode; the core only reports it | | `batch.max_urls` | `INDEXNOW_BATCH_MAX_URLS` | `10000` | URLs per request: the protocol's ceiling, not a target | | `debounce.per_url` | `INDEXNOW_DEBOUNCE_PER_URL` | `600` | seconds before the same URL is sent again (`0` = off) | | `throttle.max_requests_per_minute` | `INDEXNOW_THROTTLE_PER_MINUTE` | `60` | per-process request rate (`0` = unlimited) | | `http.timeout` | `INDEXNOW_HTTP_TIMEOUT` | `10.0` | seconds, applied to clients created by discovery | | `dry_run` | `INDEXNOW_DRY_RUN` | `false` | log the request instead of sending it | | `environment` | `INDEXNOW_ENV` / `APP_ENV` | — | anything but `prod`/`production` without a key turns `dry_run` on | Also `key_file.enabled`, `http.user_agent` and `key_location`. Every value is validated at construction, so a bad setup fails at boot, not at the first submission. Full reference, per-host overrides, `Config::with()`, `Config::OPTIONS` and `unknownOptions()`: [docs/configuration.md](configuration.md). ## Retries, queues and bulk No retries inside a web request: `429`/`5xx` come back as `retryable` results. Use `RetryingSubmitter` in CLI, cron and workers, or re-enqueue `Result::retryableUrls($results)` after `(new RetryPolicy())->delayAfter($results, $attempt)` seconds. Collect during a unit of work, deliver once: ```php $indexNow->collect(['/posts/1', '/posts/2']); // anywhere during the request $indexNow->flush(); // at the end of the unit of work ``` See [docs/retries-and-queues.md](retries-and-queues.md) for the worker recipe and bulk/migration guidance. Re-announcing a bulk change from the site's own URL list is the job of the add-on package in the family table (Install); `$kit->transport` is the transport such consumers read through. Adapters prove their wiring with `Testing\Conformance\CoreConformanceTestCase`: extend it, return the facade your container built and its `FakeTransport`, and the protocol scenarios of the spec run against it. ## Testing `IndexNowKit\Testing` is part of the published package: `FakeTransport` (records POSTs, answers queued responses), `ArrayLogger`, `FrozenClock`, `RecordingDispatcher`. ```php $transport = new FakeTransport(); $indexNow = IndexNowKit::create($config, transport: $transport, debounce: new NullDebounceStore()); $indexNow->submitEntity($post); self::assertSame(['https://www.example.com/posts/hello'], $transport->posts[0]['body']['urlList']); ``` More recipes in [docs/testing.md](testing.md). ## Extension points | Interface | Default | Replace it to | |---|---|---| | `Http\TransportInterface` | `Psr18Transport::discover()` | use your own HTTP stack (`LazyTransport` defers building it) | | `Key\KeyProviderInterface` | `StaticKeyProvider` | keys from a database, per tenant | | `Url\UrlNormalizerInterface` | `UrlNormalizer` | strip tracking parameters, enforce trailing slashes, map hosts | | `Url\UrlResolverInterface` | `NullUrlResolver` — build an `AttributeUrlResolver` and pass it as `resolver:` | turn objects into URLs your way | | `Url\RouteUrlResolverInterface` | — (adapter-provided) | bridge your framework's router | | `Attribute\AttributeReaderInterface` | `AttributeReader` | `RuleRegistry` for runtime rules, or your own metadata source | | `Collector\CollectorInterface` | `Collector` | a durable outbox, a per-tenant buffer | | `Debounce\DebounceStoreInterface` | `MemoryDebounceStore` | `Psr16DebounceStore`, or your own | | `Throttle\ThrottleInterface` | `TokenBucket` | `NullThrottle`, a shared limiter | | `Dispatch\DispatcherInterface` | `SyncDispatcher` | `CallableDispatcher` for a queue, `NullDispatcher` | | `SubmitterInterface` | `Submitter` | decorate (`RetryingSubmitter`), record, mock | Pass any of them to `IndexNowKit::create()` by name, or assemble the graph by hand: `Client` → `Submitter` → `Collector` + `DispatcherInterface` → `IndexNowKit`. The pieces a framework adapter wires from its configuration have factories with one source of error texts — `Http\TransportFactory::lazy()` (`http.client`), `Debounce\DebounceStoreFactory::fromConfig()` (`debounce.store`), `Dispatch\DispatcherFactory::fromConfig()` (`dispatch`), `fromConfig()` on `Collector`, `TokenBucket`, `AttributeUrlResolver` and `KeyFileResponder` — and `Adapter\ConfigFactory` turns a raw framework array into a `Config` without ever throwing from a hook. A container that assembles at runtime describes the whole graph once with `Adapter\ServicesBuilder` and gets it lazily from `Adapter\Services`; ORM hooks share `Hook\ObserverHelper`, queue jobs `Retry\WorkerOutcome`, commands the runners and `Console\Definitions` of `indexnowkit/console`. Writing an adapter? [docs/adapters.md](adapters.md). ## Exceptions All exceptions implement `IndexNowKit\Exception\IndexNowException`: `ConfigurationException` (invalid `Config`, attribute or resolver setup), `InvalidUrlException` (a URL that cannot be submitted, caught by `Submitter` and dropped with a warning), `InvalidArgumentException` (programming errors) and `Http\Exception\TransportException` (network failure, turned into a retryable `Result` by `Client`; consumers reading documents through the transport see it; `Checker` turns it into an error line). Nothing throws out of a lifecycle hook — see the error contract in [docs/adapters.md](adapters.md). ## Limitations - The same URL is not re-sent within `debounce.per_url` (10 minutes by default): that is what Yandex asks for. - No retries inside a web request; `TokenBucket` throttles per process. Multi-process limits belong to your queue. - Only `http(s)` URLs on hosts you hold a key for. Sub-domains are separate hosts, each with its own key file. - Bulk ORM operations bypass entity hooks in every adapter: submit those URLs yourself. - Google is not reachable through IndexNow. ## Requirements PHP 8.2+, `ext-json`, `ext-filter`, a PSR-18 client with PSR-17 factories (`symfony/http-client` and Guzzle are configured automatically with the timeout and no redirects; other clients are used as is). Optional: `ext-intl` (IDN via UTS #46, otherwise a pure-PHP punycode encoder). ## Versioning SemVer. Before 1.0, minor versions may contain breaking changes; they are listed in [CHANGELOG.md](https://github.com/indexnowkit/php/blob/main/packages/core/CHANGELOG.md). What is covered by the promise and what is not: [docs/bc.md](bc.md). ## Notes for AI assistants - Composer package `indexnowkit/core` (PHP ^8.2, PSR-18 client + PSR-17 factories needed). Framework users want an adapter instead: `indexnowkit/symfony-bundle`, `indexnowkit/laravel`, `indexnowkit/yii2`, `indexnowkit/yii3`, `indexnowkit/doctrine`. - Minimal complete snippet (every `use` included): ```php use IndexNowKit\Config; use IndexNowKit\IndexNowKit; $indexNow = IndexNowKit::create(Config::fromEnv()); // INDEXNOW_KEY, INDEXNOW_BASE_URL, INDEXNOW_DRY_RUN $results = $indexNow->submit(['/posts/hello']); // list, never throws for remote errors ``` - Verify: `(new IndexNowKit\Check\Checker($config, $indexNow->keys, $indexNow->transport))->run()` is what the adapters' `check` command runs; `$indexNow->explain($object)` shows the rule behind every URL; every remote outcome is a `Result` with `status`/`reason`, nothing throws. - Pitfalls: - `dispatch: auto` exists in Symfony (`auto` | `messenger` | `sync` | `none`) and Yii2 (`auto` | `queue` | `sync` | `none`), **not** in Laravel (`queue` | `sync` | `none`); Yii3 has `sync` | `none` only. - Locales: `router.locales` in Laravel, Yii2 and Yii3, `framework.enabled_locales` in Symfony; `locales: 'all'` on a rule uses that list. - `url:` names an accessor (method or property) that returns the URL; `urls:` is a list of literal URLs. Never put a literal in `url:`. - A string in `when:` is an accessor read as truthy (`published`, `isPublished`). A status string needs `Equals`: `when: new Equals('status', 'published')` (`IndexNowKit\Attribute\Param\Equals`). - Manual submission is `submitEntity()` in Symfony, `submitModel()` in Laravel, `submitRecord()` in Yii2 and Yii3; the commands are `indexnow:submit-entity`, `indexnow:submit-model`, `indexnow/submit-record` (Yii2), `indexnow:submit-record` (Yii3). Bulk queries (`update()`, `DB::table()`, `updateAll()`) fire no hooks: submit afterwards with those. - Laravel has two classes called `IndexNowKit`: the facade `IndexNowKit\Laravel\Facades\IndexNowKit` and the core service `IndexNowKit\IndexNowKit` (inject by type). Yii2 exposes the core through `Yii::$app->indexnow->kit()`; Yii3 defines `IndexNowKit\IndexNowKit` in the container. - Outside production a configured key with `dry_run` unset makes `check` fail (a staging copy would submit real URLs): set `dry_run: true` there, or `dry_run: false` explicitly when it submits on purpose. - Unknown configuration keys are warned about at boot (typos such as debounce.per_urls); the key list is `Config::OPTIONS` plus the adapter's own keys. ## Other packages | | | |---|---| | PHP | the family table under [Install](#install) | | JS/TS | `@indexnowkit/core`, `next`, `prisma` (planned) | | Python | `indexnowkit`, `indexnowkit-django` (planned) | Design rationale and the cross-language model: [docs/spec](https://github.com/indexnowkit/spec). Conformance suite: [indexnowkit/spec](https://github.com/indexnowkit/spec). MIT. IndexNow is a trademark of its owner; this project is independent and not affiliated with Microsoft, Yandex or indexnow.org. # Attribute reference [Русская версия](attribute-reference.ru.md) A class declares a **list of rules**, one per family of public URLs it has. PHP writes them as attributes; every other language in the family writes decorators or config objects. All of them compile down to the same `IndexNowKit\Attribute\UrlRule`, and everything downstream — event classification, guards, locales, `via` delegation, deduplication, `explain` output — consumes only that. ## The three attributes | Attribute | Target | Purpose | |---|---|---| | `#[IndexNow]` | class, **repeatable** | one URL rule | | `#[IndexNowDefaults]` | class | policy shared by every rule of the class and its subclasses | | `#[IndexNowUrl]` | public method | the method's return value is a URL family (the `get_absolute_url()` convention) | ```php use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults, IndexNowUrl}; #[IndexNowDefaults(when: 'isPublished', fields: ['slug', 'title', 'body', 'published'])] #[IndexNow(route: 'post_show', params: ['slug' => 'slug'])] #[IndexNow(route: 'post_amp', params: ['slug' => 'slug'], when: 'hasAmp', whenFields: ['ampEnabled'])] class Post {} ``` ## Sources Exactly one source per `#[IndexNow]`. Zero sources, or two, throws `ConfigurationException` at compile time with a message naming the offenders. | Source | Value | Produces | |---|---|---| | `route` | framework route name | one URL per locale, generated by the adapter's `RouteUrlResolverInterface` | | `resolver` | `UrlResolverInterface` class name or service id | whatever the resolver returns | | `via` | accessor to a related object or collection | the related objects' own URLs, resolved as updates | | `url` | accessor returning `string`, `iterable` or `null` | those URLs | | `urls` | list of literal URLs | those URLs, absolute or `base_url`-relative | `url` and `urls` are easy to swap, so both are checked: `url: '/about'` and `urls: ['aboutUrl']` are rejected with a message telling you which one you meant. `resolver` needs a `ResolverLocatorInterface`. In plain PHP that is `ArrayResolverLocator`, which also instantiates a class name on demand as long as its constructor takes no required arguments. Framework adapters look the id up in the container. ## Parameters `params` maps a route parameter name to a source. A plain string is the accessor DSL; anything else is one of four typed `Param\ParamValue` objects. ### The accessor DSL Resolved in this order, on the object itself: 1. `'self'` — the object (route model binding: `params: ['post' => 'self']`); 2. a dotted path — each segment resolved recursively (`'category.slug'`); a non-object segment throws; 3. a method with that exact name; 4. `get`, `is` or `has` plus the capitalised name (`'published'` finds `getPublished()`, then `isPublished()`, then `hasPublished()`); 5. a property, including a private one. Nothing matched throws `ConfigurationException` naming the accessor and the class. ### Typed sources | Class | Example | Meaning | |---|---|---| | `Param\Accessor` | `new Accessor('category.slug')` | the explicit form of a plain accessor string | | `Param\Value` | `new Value('html')` | a constant | | `Param\Formatted` | `new Formatted('publishedAt', 'Y')` | `DateTimeInterface::format()` of the accessor's value | | `Param\Call` | `new Call('slugFor', Placeholder::Locale)` | a method call; extra arguments are passed as given | `Param\Placeholder::Locale` and `Param\Placeholder::Host` are substituted per generated URL, so a `Call` can return a per-locale slug or a per-tenant path. Extraction runs once per URL, not once per rule. ### Coercion Route parameters must be usable in a URL. The extractor accepts `null` and scalars as they are, unwraps a `BackedEnum` to its `value`, casts a `Stringable` value object to string, and passes plain objects through for route model binding. A bare `DateTimeInterface` is rejected with a message pointing at `new Formatted(...)`, because formatting a date implicitly is how a URL silently changes shape. Anything else throws. ## Rule options | Option | Type | Default | Meaning | |---|---|---|---| | `when` | accessor name, a `Condition` (`new Equals(path, value)` or your own), or a closure `fn(object): bool` (runtime rules only) | inherit | the page exists only while the condition holds | | `whenFields` | list of field names | `[]` | fields backing this rule's own `when` when its name does not match the field (a class-level `when` has its own `whenFields` in `#[IndexNowDefaults]`) | | `fields` | list of field names, or `null` | inherit, then `[]` | for updates only: submit when one of these changed; `[]` = any field | | `events` | subset of `created`, `updated`, `deleted` (strings or `Event` cases), or `null` | inherit, then all three | which lifecycle events the rule listens to | | `locales` | `'current'`, `'all'` or a list, or `null` | inherit, then `'current'` | locale expansion for localized routes | | `host` | string or `ParamValue` | `null` | generate this rule's URLs on that host (multi-domain) | | `name` | string | derived | stable rule id for logs, `explain` output and subclass overrides | `when` is a **conjunction**: the class-level `when` and the rule's own `when` must both hold. `fields`, `events` and `locales` are defaults a rule overrides; `null` means inherit, `[]` means "no filter". An accessor string is checked for truthiness, which is right for booleans and wrong for a status string (`'draft'` is truthy). For string or enum states use `Equals`, which also gives exact old-state detection from the ORM change set: ```php use IndexNowKit\Attribute\Param\Equals; #[IndexNow(route: 'post_show', params: ['slug' => 'slug'], when: new Equals('status', 'published'))] #[IndexNow(route: 'job_show', params: ['id' => 'id'], when: new Equals('state', JobState::Open))] // BackedEnum or its value ``` Rules registered at runtime (`RuleRegistry`) may pass a closure: `when: fn (WP_Post $p): bool => $p->post_status === 'publish'`. A closure's old value cannot be reconstructed, so list the fields it reads in `whenFields`; a change of one of them is treated as a visibility flip (see the semantics table). ### Your own conditions Two interfaces, by what the condition reads. `Attribute\Param\FieldCondition` (`field()`, `heldFor(mixed $oldValue)`) reads one field: the core reads it through the graph's `ParamExtractor` (its readers see Eloquent attributes) and asks `heldFor()`, for the current value and, from the ORM change set, for the old one — `Equals` is the shipped one. `Attribute\Param\Condition` (`evaluate(object $subject): bool`) looks at the whole object itself, for what one field cannot say. Either goes in `when` (an attribute argument must be a constant expression, so a condition class with a constructor of scalars, not a closure): ```php use IndexNowKit\Attribute\Param\Condition; use IndexNowKit\Attribute\Param\FieldCondition; final readonly class OneOf implements FieldCondition // reads one field: the classifier sees the old state { /** @param list $values */ public function __construct(private string $path, private array $values) {} public function field(): string { return $this->path; } public function heldFor(mixed $oldValue): bool { return in_array($oldValue, $this->values, true); } } final readonly class Sellable implements Condition // three fields at once: reads the object itself { public function evaluate(object $subject): bool { return $subject instanceof Offer && $subject->stock > 0 && $subject->price !== null && !$subject->hidden; } } #[IndexNow(route: 'offer_show', params: ['id' => 'id'], when: new OneOf('state', ['open', 'reserved']))] ``` A `Condition` has no old value: `ChangeClassifier` evaluates it on the current object, so `open → closed` is classified as a plain update, not as the deletion it is — unless `whenFields` names the fields the condition reads (then a change of one of them counts as a flip). Implement `FieldCondition` when the condition reads one field, and the change set gives the exact old state, as it does for `Equals`. Neither interface is evaluated by hand: the graph's extractor does it (`$indexNow->extractor->condition($subject, new Equals('status', 'published'))`): a `FieldCondition` as `heldFor()` of the value the extractor reads for `field()`, so it sees Eloquent and Active Record attributes through the adapter's readers; a `Condition` as its own `evaluate()`. `Condition` and `FieldCondition` are in the Implement tier of [bc.md](bc.md), with the pre-1.0 caveat that they are new in 0.8 (and siblings since 0.12: a field condition has no `evaluate()`). `Equals` is a condition, not a value source: `params: ['status' => new Equals(...)]` is a type error, and `ParamExtractor` names the fix. `explain` prints every condition with the value it read (`when: status ("draft") -> true — a non-empty string is truthy; use new Equals('status', "draft")`), and `explain --json` gives the same walk as a document. An unknown event name throws `ConfigurationException` naming the attribute and the value. ### Rule names Derived from the source when not given: the route name; `resolver:`; `via:`; `url:` (and `url:` for `#[IndexNowUrl]`); `urls:`. Two rules of the same class that would derive the same name get `#2`, `#3` appended in declaration order. Give an explicit `name` whenever you intend a subclass to override a specific rule, or whenever the derived name would be unstable. ## Class defaults and inheritance The compiler walks the class hierarchy **root first**, then the leaf. - `#[IndexNowDefaults]` merges field by field, the nearest declaration wins. A declaration that sets its own `when` also replaces the inherited `whenFields`; one that does not adds to them. - Rules accumulate. A rule whose name repeats an ancestor's **replaces** it; a new name **adds** a page. That is how a subclass changes one page without restating the others. - `#[IndexNowUrl]` is read on public methods declared by each class in the chain, so an override in a subclass wins. The method must not require arguments. - Interfaces and traits are **not** scanned: PHP does not inherit class attributes through them, and Doctrine mapping behaves the same way. ```php #[IndexNowDefaults(when: 'isPublished')] #[IndexNow(route: 'content_show', params: ['slug' => 'slug'])] abstract class Content {} #[IndexNow(route: 'content_show', params: ['slug' => 'slug', 'section' => new Value('news')])] // replaces #[IndexNow(route: 'news_amp', params: ['slug' => 'slug'])] // adds class News extends Content {} ``` A hierarchy where only some subclasses have public pages should carry no rules on the base class: what is not declared is not inherited. ## Semantics: event, before, after Visibility is evaluated per rule. `W` is the conjunction of the class `when` and the rule's `when`; `W_before` is reconstructed from the ORM change set. | ORM event | `W_before` | `W_after` | `fields` match | Rule event | State the URL is built from | |---|---|---|---|---|---| | insert | — | true | — | `Created` if subscribed | new state, after the write (ids assigned) | | insert | — | false | — | none | — | | update | true | true | yes | `Updated` if subscribed | new state, after the write | | update | true | true | no | none | — | | update | true | false | ignored | **`Deleted`** if subscribed | current state, before the write | | update | false | true | ignored | `Created` if subscribed | new state, after the write | | update | false | false | — | none | — | | delete | — | true | — | `Deleted` if subscribed | pre-delete state | | delete | — | false | — | none | — | | `via` target, any event | — | true | per target rule | target resolved as `Updated` | the target's own rules and guards | | `via` target, any event | — | false | — | none | — | Two consequences worth stating out loud. `fields` never suppresses a visibility transition, only a plain update: a page that just went dark is announced whichever field did it. And deleting an object whose rule does not apply submits nothing — that page was never public, so purging drafts stays quiet. ### Reconstructing `W_before` `ChangeClassifier::classify(UrlRule $rule, object $subject, ParamExtractor $extractor, array $changedFields, array $changeSet = [])` returns the `Event` a rule cares about, or `null`. Old-state visibility is best effort, in three tiers: 1. A `when` accessor whose backing field is present in the change set is evaluated **exactly** from the old value. The backing field is found by name, then by convention: `isPublished → published → is_published`, `hasAmp → amp → has_amp`, `getStatus → status`. `UrlRule::fieldCandidates()` exposes that list. 2. An accessor with no change-set entry, but a field it depends on (its candidates, or a declared `whenFields` entry) among the changed fields, is assumed to have **flipped**. A false positive costs one request; a false negative leaves a dead page in the index. 3. Otherwise the accessor keeps its current value. Name the fields with `whenFields` when the accessor is a method whose name has nothing to do with the column, for example `when: 'isVisibleToPublic', whenFields: ['status', 'visibleFrom']`. ## `via` `via` resubmits a related object's pages: a changed comment refreshes its post, a changed product refreshes its category. Targets are always resolved as `Updated`, because their page exists regardless of what happened to the source. Depth is capped at 3 and fan-out at 100 related objects per rule (constructor arguments `maxViaDepth` and `maxViaFanout` of `AttributeUrlResolver`); exceeding the depth throws, exceeding the fan-out logs a warning and stops. A target rule that delegates back through the same accessor name is skipped, so `A -> B -> A` terminates. Resulting URLs keep the whole chain in their rule name: `via:category -> category_show`. ## Field names `fields` and `whenFields` are **model field names** as the developer writes them, never database columns. Doctrine's `getEntityChangeSet()` gives exactly those. A declared field matches a changed one when they are equal or one is a dotted prefix of the other, so `fields: ['address']` catches an embeddable change reported as `address.city`. ## Types adapters consume ```php final readonly class UrlRule { public string $name; public RuleSource $source; // Route|Resolver|Via|Url|Urls public ?string $route; public array $params; public ?string $resolver; public ?string $via; public ?string $url; public array $urls; public array $when; public array $whenFields; public array $fields; public array $events; public array|string $locales; public string|ParamValue|null $host; public function listensTo(Event $event): bool; public function caresAbout(array $changedFields): bool; public function appliesTo(object $subject): bool; // every `when` accessor is truthy public function whenDependsOn(string $field): bool; public static function fieldCandidates(string $accessor): array; } ``` `RuleSet` is every rule of one class in declaration order (parents first). It is `Countable` and iterable, empty for classes without rules so callers never branch on null, and offers `isEmpty()`, `get(string $name)` and `listensTo(Event $event)` as a cheap pre-filter for ORM hooks. `AttributeReaderInterface::rules(string|object $classOrObject): RuleSet` is the lookup. The default `AttributeReader` compiles attributes through `RuleCompiler` and caches per class for the process lifetime. It throws `ConfigurationException` on a malformed declaration — ORM hooks must read through `ObjectChangeHandler` or `GuardedUrlResolver`, which log instead. `ResolvedUrl` carries provenance for `explain` output, logs and profiler panels: `url`, `rule`, `class`, `event`, `locale`, plus `source()` (`App\Entity\Post#post_amp`) and `ResolvedUrl::urls()` to flatten a list to deduplicated strings. ## Rules registered at runtime Models that cannot carry attributes — CMS post types, classes you do not own, a closure API — use `RuleRegistry`, which implements `AttributeReaderInterface` on top of an inner reader (attributes by default). ```php use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults, RuleRegistry}; $registry = new RuleRegistry(); $registry->register(Post::class, [ new IndexNow(route: 'posts.show', params: ['post' => 'self']), new IndexNow(urls: ['/']), ], new IndexNowDefaults(when: 'isPublished')); $registry->register(WP_Post::class, [new IndexNow(resolver: 'wp_permalink')], new IndexNowDefaults( when: fn (WP_Post $post): bool => $post->post_status === 'publish', // or: new Equals('post_status', 'publish') whenFields: ['post_status'], )); $registry->registerFor(CmsPage::class, fn (CmsPage $page): ?RuleSet => $page->isSystem() ? null : $rulesFor($page)); $indexNow = IndexNowKit::create($config, attributes: $registry); ``` `register()` compiles attribute instances built in code, with no reflection. `registerFor()` decides per object and may return `null` to fall through to the inner reader. Registered rules replace whatever the inner reader would return for that class, and subclasses inherit them. ## Resolving without an ORM `AttributeUrlResolver` resolves every rule of a class through its source; `GuardedUrlResolver` wraps it so nothing throws. `ObjectChangeHandler` is the piece ORM hooks build on: it classifies a created, updated or deleted object per rule and resolves the URLs, logging every silent outcome. See [adapters.md](adapters.md). ## Anti-patterns Five declarations that compile, run, and submit the wrong thing. **1. A literal URL in `url:`.** `url:` names an accessor; `urls:` lists literals. ```php #[IndexNow(url: '/')] // wrong: reads a property or method called "/" #[IndexNow(urls: ['/'])] // right #[IndexNow(url: 'canonicalUrl')] // right: $post->canonicalUrl() or ->canonicalUrl ``` **2. A status string in `when:`.** A string is an accessor read as truthy, so `'published'` means "the attribute `published` is truthy" — a `status` column holding `'draft'` is truthy too. `explain` shows the value it read and says so. ```php #[IndexNow(route: 'post_show', params: ['slug' => 'slug'], when: 'status')] // wrong: 'draft' is truthy #[IndexNow(route: 'post_show', params: ['slug' => 'slug'], when: new Equals('status', 'published'))] // right #[IndexNow(route: 'post_show', params: ['slug' => 'slug', 'v' => new Equals('status', 'published')])] // wrong: a condition is not a param value (type error) ``` **2b. A custom `Condition` on a field that flips.** `when: new Published()` that reads `status` internally cannot tell the classifier what the old state was: `published → draft` is an update, the dead page stays indexed. Implement `FieldCondition`, or name the field in `whenFields`. **3. A rule on a page the engine must not index.** A preview, an admin page, a page with `noindex` or a `robots.txt` disallow: the engine fetches it, finds it unindexable, and counts a mistake against the key. The [`indexnowkit/verify`](../verify/index.md) add-on catches what the rule lets through (one GET before submission; `check --sample` for a dry report) — the rule is still the right place. ```php #[IndexNow(route: 'post_preview', params: ['slug' => 'slug'])] // wrong: preview pages carry noindex #[IndexNow(route: 'post_show', params: ['slug' => 'slug'], when: 'isPublished')] // right: the public page, guarded ``` **4. Non-canonical URLs.** Filter and sort variants, tracking parameters, the apex next to `www`, `http` next to `https`: submit the canonical page once. Generate URLs through the router with `base_url` on the canonical origin; do not build them by string concatenation from the request. ```php #[IndexNow(urls: ['/products?sort=price&utm_source=indexnow'])] // wrong: a variant, and a tracking parameter #[IndexNow(route: 'products_index')] // right: the canonical listing ``` **5. No `when` on a model that has drafts.** Without a guard every save submits, drafts included, and a page taken down is announced as an update, not a deletion. ```php #[IndexNow(route: 'post_show', params: ['slug' => 'slug'])] // wrong when Post has a draft state #[IndexNowDefaults(when: 'isPublished')] // right: drafts are skipped, #[IndexNow(route: 'post_show', params: ['slug' => 'slug'])] // published → draft is a deletion ``` What the library checks for you: the URL is absolute `http(s)`, has no credentials, fragment or control characters, and belongs to a host you hold a key for (`strict_hosts`). What it cannot check: `noindex`, `robots.txt`, a canonical pointing elsewhere, the status code the page answers — that is the rule author's job today, and the job of the `verify` add-on (`check --sample`) in a later release. # Configuration [Русская версия](configuration.ru.md) `IndexNowKit\Config` is an immutable value object shared by every adapter. It is built in one of three ways and validated in the constructor, so a broken setup fails at boot instead of at the first submission. ```php use IndexNowKit\Config; $config = Config::fromArray([...]); // framework config files $config = Config::fromEnv(); // INDEXNOW_* environment variables $config = new Config(key: '...', baseUrl: '...'); // named arguments $config = $config->with(dryRun: true); // immutable copy ``` ## Options `fromArray()` takes the nested shape below; it is the canonical schema every language adapter mirrors. ```php Config::fromArray([ 'enabled' => true, 'key' => $_ENV['INDEXNOW_KEY'], 'hosts' => [ 'www.example.com' => 'KEY-FOR-EXAMPLE', 'shop.example.com' => [ 'key' => 'KEY-FOR-SHOP', 'key_location' => 'https://shop.example.com/keys/indexnow.txt', 'base_url' => 'https://shop.example.com', ], ], 'strict_hosts' => true, 'key_location' => null, 'base_url' => 'https://www.example.com', 'engines' => ['api'], 'dispatch' => 'sync', 'batch' => ['max_urls' => 10000], 'debounce' => ['per_url' => 600], 'throttle' => ['max_requests_per_minute' => 60], 'http' => ['timeout' => 10.0, 'user_agent' => null], 'serve_key_file' => true, 'dry_run' => false, 'environment' => $_ENV['APP_ENV'] ?? null, ]); ``` | Option | Constructor argument | Default | Meaning | |---|---|---|---| | `enabled` | `enabled` | `true` | `false` drops every submission; the URLs come back as `skipped` results with reason `disabled`, logged at `info` | | `key` | `key` | `null` | default key, 8-128 characters of `[A-Za-z0-9-]`, used for every host not listed in `hosts` | | `hosts` | `hosts` | `[]` | `host => key`, or `host => {key, key_location?, base_url?}` | | `strict_hosts` | `strictHosts` | `false` | apply the default key **only** to the `base_url` host; every other host needs a `hosts` entry or its URLs are skipped | | `key_location` | `keyLocation` | `null` | absolute URL of the key file when it is not `https://{host}/{key}.txt` | | `base_url` | `baseUrl` | `null` | absolute site URL; resolves relative URLs and is required outside HTTP requests | | `engines` | `engines` | `['api']` | engine names (`api`, `yandex`, `bing`, `naver`, `seznam`, `yep`, `internetarchive`, `amazon`) or full endpoint URLs | | `dispatch` | `dispatch` | `'sync'` | delivery mode defined by the adapter; the core validates the identifier and reports it | | `batch.max_urls` | `batchMaxUrls` | `10000` | URLs per request; `Config::MAX_BATCH_URLS` is the protocol maximum — a ceiling, not a target: smaller batches are accepted just as well | | `debounce.per_url` | `debouncePerUrl` | `600` | seconds during which the same URL is not re-sent; `0` disables debouncing | | `throttle.max_requests_per_minute` | `throttleMaxRequestsPerMinute` | `60` | outgoing requests per minute, per process; `0` = unlimited | | `http.timeout` | `httpTimeout` | `10.0` | seconds, applied only to clients the library creates itself | | `http.user_agent` | `userAgent` | `null` | overrides `indexnowkit-php/ (+https://github.com/indexnowkit/php)` | | `key_file.enabled` | `serveKeyFile` | `true` | whether an adapter should answer `GET /{key}.txt`; `serve_key_file` is the deprecated name and wins when both are set | | `key_file.cache_max_age` | `keyFileMaxAge` / `keyFileHeaders()` | `300` | `Cache-Control: max-age` of the key file response; short on purpose, a cached old file turns every submission into a 403 after a rotation. `keyFileHeaders()` adds `Vary: Host` whenever the body depends on the host — a `hosts` map, **or** `strict_hosts`, where the default key is served for the base host only and every other host gets a 404; without the header a shared cache would keep whichever of the two answers came first | | `debounce.store` | `debounceStore` | `null` | `memory` (per process), `none`, or an id the adapter resolves to its shared cache; `null` = the adapter's default (Laravel `cache`, bundle `cache.app`, Yii2 `cache`, Yii3 the container id `Psr\SimpleCache\CacheInterface`, plain PHP `memory`) | | `http.client` | `httpClient` | `null` | id or class of a PSR-18 client the adapter resolves; `null` = discovery. It carries the application's own settings, so `check` warns when it is set (`http.client`): a client that follows redirects internally turns a 30x to a catch-all page into a 200 for the key file check. The pre-flight of `indexnowkit/verify` does **not** use it — a followed redirect would hide exactly the 3xx the pre-flight exists to see, so verify builds its own client from `verify.timeout` and `verify.max_redirects` | | `dry_run` | `dryRun` | `false` | log the request instead of sending it | | `environment` | `environment` | `null` | application environment; drives the non-production safety net below | | `production_environments` | `productionEnvironments` | `['prod', 'production']` | environment names (case-insensitive) that count as production; replaces the default list | | `previous_key` | `previousKey` | `null` | the key before a rotation: still accepted by the key file, never submitted; also `hosts..previous_key` | | `hosts..engines` | `hostEngines` / `endpointsFor()` | inherit `engines` | engines for one host only | | `engine_aliases` | `engineAliases` / `resolveEngine()` | `{}` | short names for custom endpoints, usable wherever an engine is named | | `locale_hosts` | `localeHosts` / `hostForLocale()` | `{}` | locale => host; rules with `locales` and no `host` generate each locale on its host | | `logging.max_body` | `logBody` | `300` | bytes of an engine response body kept in a failure log line | | `max_url_length` | `maxUrlLength` | `2048` | URLs above it are skipped as `invalid_url` | | `debounce.key_prefix` | `debounceKeyPrefix` | `'indexnowkit_'` | cache key prefix of a shared debounce store | | `logging.max_urls` | `logUrls` / `logSample()` | `20` | URLs listed in one log line; `0` = counts only | | `logging.forbidden_escalation` | `forbiddenEscalation` | `5` | consecutive 403s per host before the log escalates to `critical` | | `logging.levels` | `logLevels` / `logLevel()` | `{}` | per-outcome PSR-3 level overrides; events and defaults in `Config::LOG_EVENTS` | | `retry.max_attempts`, `retry.base_delay`, `retry.multiplier`, `retry.max_delay`, `retry.server_error_delay` | `retryPolicy()` | `3`, `60`, `2.0`, `3600`, `5` | the `RetryPolicy` for queue handlers and `RetryingSubmitter` | | `resolver.max_via_depth`, `resolver.max_via_fanout` | `resolverMaxViaDepth`, `resolverMaxViaFanout` | `3`, `100` | limits of `via:` traversal in `AttributeUrlResolver`. `IndexNowKit::create()` does not build that resolver: the adapter that does passes `resolverMaxViaDepth`, `resolverMaxViaFanout` and `localeHosts` to it | | `collector.max_urls` | `collectorMaxUrls` | `0` | `IndexNowKit::collect()` flushes early at this size; `0` = only on `flush()` | | `collector.detect_leaks` | `collectorDetectLeaks` | `true` | shutdown warning about collected, never flushed URLs | | `normalizer.strip_tracking_params` | `normalizerStripTrackingParams` | `true` | drop `utm_*`, `gclid`, `fbclid`, `yclid`, … (`Url\CanonicalUrlNormalizer::TRACKING_PARAMS`, a growing list) from the query before de-duplication, debounce and submission: external traffic sources append them, routing never generates them | | `normalizer.tracking_params` | `normalizerTrackingParams` | `[]` | more query parameters to drop: names (`ref`) or prefixes (`mtm_*`), case-insensitive | | `normalizer.trailing_slash` | `normalizerTrailingSlash` | `'keep'` | `keep` submits the path as generated; `add` ends every path without an extension with `/`; `strip` removes the trailing `/` except on the root. Only when the site has a canonical form: the two forms are different pages otherwise | | `normalizer.sort_query` | `normalizerSortQuery` | `false` | order the query parameters by name (stable), so `?b=1&a=2` and `?a=2&b=1` are one URL | The `normalizer.*` options are applied by `Url\UrlNormalizerFactory::fromConfig()`, which every adapter and `IndexNowKit::create()` use to build the normalizer: `Url\UrlNormalizer` (absolute URL, host, port, dot-segments) wrapped in `Url\CanonicalUrlNormalizer`. Turning `strip_tracking_params` on or off changes the debounce keys of URLs that carried such parameters once. What `Url\UrlNormalizer` does unconditionally, with no option behind it: the scheme and the host are lower-cased and an internationalized host becomes punycode, the default port is dropped, dot-segments are removed, the fragment is cut, and the percent-encoding of the path and the query is brought to the canonical form of RFC 3986 §6.2.2 — a percent-escape of an unreserved character (`A-Za-z0-9-._~`) becomes the character, every other escape is upper-cased, so `/%7Euser/a%2Db` and `/~user/a-b` are one URL and are debounced, submitted and recorded once. A URL with credentials, control characters or a non-`http(s)` scheme is rejected with `InvalidUrlException` instead. Constants worth referencing instead of hard-coding: `Config::MAX_BATCH_URLS` (10000), `Config::DEFAULT_BATCH_MAX_URLS`, `Config::DEFAULT_DEBOUNCE_PER_URL` (600), `Config::DEFAULT_THROTTLE_PER_MINUTE` (60), `Config::DEFAULT_HTTP_TIMEOUT` (10.0), `Config::PRODUCTION_ENVIRONMENTS` (`['prod', 'production']`), `Config::DEFAULT_MAX_URL_LENGTH`, `Config::DEFAULT_LOG_URLS`, `Config::DEFAULT_FORBIDDEN_ESCALATION`, `Config::DEFAULT_RETRY_*`, `Config::DEFAULT_RESOLVER_MAX_VIA_*`, `Config::LOG_EVENTS`. ## One concept, four keys The adapters share the core keys under the same names and add a few of their own; some concepts have a different key (or a different value set) per framework. The tables below are generated from the code (`bin/config-table`) and checked in CI, so they are the current truth; the prose of each adapter's `docs/configuration.md` explains the semantics. _Generated by `bin/config-table` from `Config::OPTIONS`, `SitemapConfig::OPTIONS`, the bundle configuration tree, `ConfigFactory::LARAVEL_OPTIONS`, `ConfigFactory::YII_OPTIONS` and `ConfigFactory::YII3_OPTIONS`; do not edit by hand._ ### Core keys: the same name in every adapter Every key of `Config::OPTIONS` is accepted under this name by the Symfony bundle (`indexnowkit:`), the Laravel package (`config/indexnow.php`), the Yii2 component (`options`) and the Yii3 params block (`indexnowkit/yii3`). The default column is the one the core ships, as the bundle declares it in its configuration tree (`—` = unset); the two exceptions are in the synonyms table: `dispatch` (`auto` in Symfony and Yii2, `queue` in Laravel, `sync` in Yii3) and `debounce.store` (`cache.app` / `cache` / `cache` / the PSR-16 `CacheInterface` of the container). `environment` comes from `kernel.environment` / `APP_ENV` / `YII_ENV` unless set. | Key | Default | |---|---| | `enabled` | `true` | | `key` | — | | `hosts` | `[]` | | `key_location` | — | | `base_url` | — | | `engines` | `[api]` | | `dispatch` | `auto` | | `serve_key_file` | deprecated alias of `key_file.enabled` | | `dry_run` | `false` | | `strict_hosts` | `false` | | `environment` | — | | `production_environments` | `[prod, production]` | | `max_url_length` | `2048` | | `previous_key` | — | | `key_file.enabled` | `true` | | `key_file.cache_max_age` | `300` | | `batch.max_urls` | `10000` | | `debounce.per_url` | `600` | | `debounce.key_prefix` | `indexnowkit_` | | `debounce.store` | `cache.app` | | `throttle.max_requests_per_minute` | `60` | | `http.timeout` | `10` | | `http.user_agent` | — | | `http.client` | — | | `logging.max_urls` | `20` | | `logging.forbidden_escalation` | `5` | | `logging.levels` | `[]` | | `logging.max_body` | `300` | | `engine_aliases` | `[]` | | `locale_hosts` | `[]` | | `retry.max_attempts` | `3` | | `retry.base_delay` | `60` | | `retry.multiplier` | `2` | | `retry.max_delay` | `3600` | | `retry.server_error_delay` | `5` | | `resolver.max_via_depth` | `3` | | `resolver.max_via_fanout` | `100` | | `collector.max_urls` | `0` | | `collector.detect_leaks` | `true` | | `normalizer.strip_tracking_params` | `true` | | `normalizer.tracking_params` | `[]` | | `normalizer.trailing_slash` | `keep` | | `normalizer.sort_query` | `false` | `hosts` (per-host keys, `hosts..{key, key_location, base_url, engines, previous_key}`) is accepted everywhere too. ### Sitemap keys (`indexnowkit/sitemap`) The `sitemap` block is the same in the four adapters and is owned by the sitemap package: `sitemap.enabled`, `sitemap.url`, `sitemap.max_depth`, `sitemap.max_sitemaps`, `sitemap.max_bytes`, `sitemap.allow_foreign_hosts`, `sitemap.spool`, `sitemap.spool_dir`, `sitemap.fetch_retries`. ### Verify keys (`indexnowkit/verify`) The `verify` block is the same in the four adapters and is owned by the verify package (its `docs/configuration.md` has the table): `verify.enabled`, `verify.redirect`, `verify.non_canonical`, `verify.origin_error`, `verify.delay`, `verify.timeout`, `verify.max_redirects`, `verify.max_batch`, `verify.time_budget`, `verify.robots_cache_ttl`, `verify.user_agent`. ### History keys (`indexnowkit/history`) The `history` block is the same in the four adapters and is owned by the history package (its `docs/configuration.md` has the table): `history.store`, `history.limit`, `history.key_prefix`, `history.pdo.dsn`, `history.pdo.service`, `history.pdo.table`, `history.retention_days`. ### One concept, four keys | Concept | Symfony (`indexnowkit:`) | Laravel (`config/indexnow.php`) | Yii2 (`options`) | Yii3 (`indexnowkit/yii3` params) | Notes | |---|---|---|---|---|---| | Delivery mode | `dispatch` | `dispatch` | `dispatch` | `dispatch` | `auto` (Messenger when a transport is set, else `sync`), `messenger`, `sync`, `none` — Symfony; `queue` (default), `sync`, `none` — Laravel, no `auto`; `auto` (default: `queue` when the queue component exists, else `sync`), `queue`, `sync`, `none` — Yii2; `sync` (default), `none` — Yii3 (a queue is a replaced `DispatcherInterface`) | | Queue / transport | `messenger.transport` | `queue.connection` | `queue.component` | — | Symfony: a `framework.messenger.transports` name (the bundle routes `SubmitUrlsMessage` to it); Laravel: a `queue.connections` name (default: the app default); Yii2: the yii2-queue component id (default `queue`); Yii3: none until yiisoft/queue is released | | Queue delay / extras | `messenger.delay` | `queue.delay` | `queue.delay` | — | Symfony also `messenger.stamps`, `messenger.bus`; Laravel also `queue.queue`; Yii2 also `queue.ttr`, `queue.priority` | | Locales for `locales: all` | `framework.enabled_locales` | `router.locales` | `router.locales` | `router.locales` | Symfony reads the framework setting; Laravel, Yii2 and Yii3 list them in the package configuration (`router.locale_parameter` names the route parameter, `_language` in Yii3; `router.set_app_locale` switches the application locale while generating in Laravel and Yii2; Yii2 read `router.languages` / `language_parameter` / `set_app_language` before 0.12 and still accepts them) | | ORM hook switch | `doctrine.enabled` | `eloquent.enabled` | `active_record.enabled` | `active_record.enabled` | Symfony also `doctrine.listener_priority`, `doctrine.connections`; Yii2 and Yii3 also `active_record.models` (classes you cannot annotate); Yii3 also `active_record.namespaces` (short class names of the commands) | | Key file route | `key_file.path` | `key_file.path` | `key_file.pattern` | `key_file.pattern` | Symfony/Laravel: a path with `{key}` (default `/{key}.txt`); Yii2: a URL rule pattern (default `.txt`); Yii3: a yiisoft/router pattern (default `/{key:[A-Za-z0-9-]{8,128}}.txt`); all four: `key_file.enabled`, `key_file.cache_max_age`; Symfony/Laravel also `key_file.host`, `key_file.route_name`; Laravel also `key_file.middleware` | | Log destination | `logging.channel` | `logging.channel` | `logging.category` | `logging.category` | Monolog channel (Symfony, default `indexnow`), log channel name (Laravel), Yii log category (Yii2 and Yii3, default `indexnow`) | | Debounce store | `debounce.store` | `debounce.store` | `debounce.store` | `debounce.store` | Same key, different values: a PSR-6 pool service id (Symfony, default `cache.app`), a cache store name (Laravel, default `cache` = the default store), a cache component id (Yii2, default `cache`), a PSR-16 container id (Yii3, default `Psr\SimpleCache\CacheInterface`); `memory` and `none` everywhere | | HTTP client | `http.client` | `http.client` | `http.client` | `http.client` | Same key: a service id (PSR-18 or symfony/http-client) in Symfony, a container binding or class in Laravel, a component id or class in Yii2, a container id in Yii3; unset = PSR-18 discovery | ### Adapter-only keys | Adapter | Keys | |---|---| | Symfony | `messenger.bus`, `messenger.transport`, `messenger.delay`, `messenger.stamps`, `key_file.path`, `key_file.host`, `key_file.route_name`, `logging.channel`, `flush.priority`, `flush.console_priority`, `profiler.enabled`, `doctrine.enabled`, `doctrine.listener_priority`, `doctrine.connections` | | Laravel | `queue.connection`, `queue.queue`, `queue.delay`, `key_file.path`, `key_file.host`, `key_file.route_name`, `key_file.middleware`, `router.locales`, `router.locale_parameter`, `router.set_app_locale`, `eloquent.enabled`, `logging.channel` | | Yii2 | `queue.component`, `queue.ttr`, `queue.delay`, `queue.priority`, `key_file.pattern`, `router.locales`, `router.locale_parameter`, `router.set_app_locale`, `router.languages`, `router.language_parameter`, `router.set_app_language`, `active_record.enabled`, `active_record.models`, `logging.category` | | Yii3 | `key_file.pattern`, `router.locales`, `router.locale_parameter`, `active_record.enabled`, `active_record.namespaces`, `active_record.models`, `logging.category`, `checks` | ## Environment variables `Config::fromEnv()` reads `getenv()` merged with `$_SERVER` and `$_ENV`. Pass your own array as the first argument to read from somewhere else, and a second argument to change the `INDEXNOW_` prefix. Empty strings count as unset. **Environment over a file: `Config::arrayFromEnv()`.** The same variables as the nested array `fromArray()` takes, holding only the variables that are set (values as strings; `fromArray()` coerces them), so an application without a framework merges them over its configuration file with the environment winning: `Config::fromArray(array_replace_recursive($file, Config::arrayFromEnv()))`. `toArray()` is not the tool for that merge — it carries every default. `Config::fromArray(Config::arrayFromEnv($env))` equals `Config::fromEnv($env)`. The `indexnow` CLI of [`indexnowkit/cli`](../cli/index.md) reads its configuration this way, and extends the rule to the blocks of the optional packages (`INDEXNOW__`: `INDEXNOW_SITEMAP_MAX_DEPTH`, `INDEXNOW_HISTORY_PDO_DSN`). **Booleans are parsed, not cast.** Every boolean option — `enabled`, `dry_run`, `strict_hosts`, `key_file.enabled`, `serve_key_file`, `collector.detect_leaks`, `normalizer.strip_tracking_params`, `normalizer.sort_query` — goes through the same parser (`filter_var` with the boolean filter) in `fromEnv()` **and** in `fromArray()`, so the strings `false`, `0`, `no` and `off` mean false and `true`, `1`, `yes`, `on` mean true. An empty string is "not set" and falls back to the default; a non-scalar is a `ConfigurationException` naming the key. This matters wherever an adapter hands an environment variable straight to `fromArray()` without a cast of its own — Yii3's params block does, and a plain `(bool)` there would read `INDEXNOW_DRY_RUN=false` as true and quietly submit nothing while `check` reported it as a deliberate choice. | Variable | Option | |---|---| | `INDEXNOW_ENABLED` | `enabled` (any boolean literal `filter_var` accepts) | | `INDEXNOW_KEY` | `key` | | `INDEXNOW_PREVIOUS_KEY` | `previous_key`: the key before a rotation, still served and accepted by the key file, never submitted | | `INDEXNOW_HOSTS` | `hosts`, as `host=key,host2=key2`; per-host `key_location`/`base_url` need `fromArray()` | | `INDEXNOW_STRICT_HOSTS` | `strict_hosts` | | `INDEXNOW_KEY_LOCATION` | `key_location` | | `INDEXNOW_BASE_URL` | `base_url` | | `INDEXNOW_ENGINES` | `engines`, comma-separated (`api` or `yandex,bing`) | | `INDEXNOW_DISPATCH` | `dispatch` | | `INDEXNOW_BATCH_MAX_URLS` | `batch.max_urls` | | `INDEXNOW_DEBOUNCE_PER_URL` | `debounce.per_url` | | `INDEXNOW_THROTTLE_PER_MINUTE` | `throttle.max_requests_per_minute` | | `INDEXNOW_HTTP_TIMEOUT` | `http.timeout` | | `INDEXNOW_USER_AGENT` | `http.user_agent` | | `INDEXNOW_KEY_FILE_ENABLED` (`INDEXNOW_SERVE_KEY_FILE` still wins) | `key_file.enabled` | | `INDEXNOW_KEY_FILE_CACHE_MAX_AGE` | `key_file.cache_max_age` | | `INDEXNOW_DEBOUNCE_STORE` | `debounce.store` | | `INDEXNOW_HTTP_CLIENT` | `http.client` | | `INDEXNOW_DRY_RUN` | `dry_run` | | `INDEXNOW_ENV`, else `APP_ENV` | `environment` | | `INDEXNOW_PRODUCTION_ENVIRONMENTS` | `production_environments`, comma-separated | | `INDEXNOW_MAX_URL_LENGTH` | `max_url_length` | | `INDEXNOW_LOG_URLS`, `INDEXNOW_FORBIDDEN_ESCALATION` | `logging.max_urls`, `logging.forbidden_escalation` | | `INDEXNOW_RETRY_MAX_ATTEMPTS`, `INDEXNOW_RETRY_BASE_DELAY`, `INDEXNOW_RETRY_MULTIPLIER`, `INDEXNOW_RETRY_MAX_DELAY`, `INDEXNOW_RETRY_SERVER_ERROR_DELAY` | `retry.*` | ## Hosts, keys and `strict_hosts` Sub-domains are separate hosts for IndexNow: each needs its own key file. Three layouts: - **One site.** Set `key` and `base_url`. Every host you submit uses that key. - **Several sites, one key each.** Fill `hosts`. Hosts missing from the map still fall back to `key`. - **Several sites, nothing else.** Set `strict_hosts: true`. The default key then applies only to the `base_url` host; URLs of any other unlisted host are skipped with reason `no_key` instead of being announced under someone else's key. Recommended whenever URLs can come from user input or from a multi-tenant database. `hosts..key_location` overrides the key file URL for that host only, and must be on that host. `hosts..base_url` gives the host its own absolute base for URL generation outside a request — a console command or a queue worker has no request context, so without it every site would be generated on the single global `base_url`. `Config::baseUrlFor($host)` returns that per-host base, falling back to `base_url` when the host is the base host, and `null` otherwise. Keys can be enumerated with `Config::$hosts`, `Config::$keyLocations` and `Config::$hostBaseUrls` (all lower-cased host maps). To load keys from a database or a tenant registry, implement `Key\KeyProviderInterface` instead. ## The dry-run safety net `Config::fromArray()` switches `dry_run` on by itself when **all** of these hold: no `key`, no `hosts`, an `environment` is given, and it is not in `production_environments` (default `Config::PRODUCTION_ENVIRONMENTS`). A developer who never sets `INDEXNOW_KEY` locally therefore gets logging instead of a boot failure, and never reaches the real API. The reverse case is worth alerting on: `dry_run` on while `environment` says production means nothing is being submitted at all. `Config::isProduction()` reports it, and `Check\Checker` raises it as an **error** rather than a warning in that combination. ## Validation The constructor throws `Exception\ConfigurationException` for: - `enabled` without `key`, `hosts` or `dry_run`; - a `key` (or any host key) outside `[A-Za-z0-9-]{8,128}`; - a `hosts` key that is not a bare host name (scheme, port or path present); - `base_url` that is not an absolute `http(s)` URL, or carries credentials; - `key_location` that is not an absolute `http(s)` URL with a path, or is not on the `base_url` host — engines only accept a key file served from the submitted host; - `hosts..key_location` or `hosts..base_url` pointing at a different host; - `batch.max_urls` outside `1..10000`, negative `debounce.per_url` or `throttle.max_requests_per_minute`, `http.timeout` at or below zero, an empty `engines` list; - a `dispatch` value that is not a short identifier, a `http.user_agent` containing line breaks; - `strict_hosts` without any known host; - an engine name that is neither a known engine nor an `https` endpoint (plain `http` is allowed only on loopback hosts, for mock servers). `Config::fromArray()` additionally rejects non-numeric values for numeric options rather than silently falling back to the default. ## Deriving configurations `with()` takes constructor argument names and returns a validated copy; an unknown name throws. ```php $probe = $config->with(dryRun: false, engines: ['yandex']); $config->withDryRun(true); // shorthand $config->userAgent(); // the effective User-Agent string $config->baseHost(); // lower-cased host of base_url, or null ``` ## Detecting typos in adapter config `Config::OPTIONS` lists every key `fromArray()` understands, in dotted form. `Config::unknownOptions($data, $allowed)` returns the keys of an array that are neither core options nor listed in `$allowed`, so an adapter can warn about `debounce.per_urls` instead of silently ignoring it. List nested keys as `block.key`, never as a bare `block`: a bare name stops the check from looking inside the block. Adapters get this through `Adapter\ConfigFactory::load()` (`ownedOptions:`), which also merges the adapter's defaults, resolves `dispatch: auto` and turns an invalid value into a `critical` log line and a disabled `Config` instead of an exception. ```php $unknown = Config::unknownOptions($userConfig, ['messenger', 'messenger.bus', 'doctrine.enabled']); if ($unknown !== []) { $logger->warning('indexnow: unknown option(s): {options}', ['options' => implode(', ', $unknown)]); } ``` Nested arrays are checked one level deep by dotted path; `hosts` is always accepted because its keys are host names. Naming a block in `$allowed` (for example `messenger`) allows the whole block, so an adapter lists either the block name or the individual dotted paths it owns. # Operations Everything here is about the question an operator actually asks: *my page changed, why was nothing submitted?* — and, before that, about not shipping a setup that submits the wrong thing. ## Production checklist Before the first real submission, and again after every deployment that touches the configuration: 1. **Key and base URL.** `INDEXNOW_KEY` (8–128 characters of `[A-Za-z0-9-]`) and `base_url` are set; every host you submit serves `https:///.txt` with `200`, `text/plain`, the key as the body and no redirect. 2. **`check --strict` is green** in the environment that submits (`bin/console indexnow:check --strict`, `php artisan indexnow:check --strict`, `php yii indexnow/check --strict` in Yii2, `./yii indexnow:check --strict` in Yii3): exit code 0. Put it in the deploy pipeline; it exits 1 on any error and, with `--strict`, on any warning. `check --json` (schema `docs/check.schema.json` of `indexnowkit/console`, codes in [check-codes.md](check-codes.md)) is the form for monitoring: alert on `status` and on the codes, never on the texts. `config --json` is what to paste into a bug report. With `indexnowkit/verify`, `check --sample=` (or `--sample-class=`) fetches a few of your own pages and reports noindex, robots.txt, canonical and redirects as warnings; with `indexnowkit/history`, `status` prints the switches, the 403 counters and the last successful submission ([Status and history](#status-and-history)). 3. **`strict_hosts: true`** whenever a `hosts` map exists or the application answers under more than one hostname (a staging copy, an internal name, the apex next to `www`). 4. **A shared debounce store.** `debounce.store` is a cache that web requests and workers share, not `memory`. 5. **The queue is monitored.** `dispatch: queue` / `messenger` runs a worker; failed jobs are visible; the 403 "rejected permanently" line has an owner. 6. **Staging cannot submit.** Outside production set `INDEXNOW_DRY_RUN=1` (or `INDEXNOW_ENABLED=0`) and `key_file.enabled: false`, so the staging host neither sends nor serves the production key. Since core 0.6, `check` fails on a staging copy that has a key and no `dry_run` setting; a preview environment that submits on purpose says `dry_run: false` explicitly. 7. **Alerts on three lines**: the 403 escalation (`critical`), `invalid configuration, IndexNow is disabled` (`critical`), and `collected URL(s) discarded` (`warning`). The monitoring rules below say how. 8. **Short key-file caching.** `key_file.cache_max_age` ≤ 300 and the CDN honours it: after a rotation the old file must not be served for a day. 9. **`previous_key` removed** once every engine answers 200 for the new key (`check --live`). 10. **Someone looks at the result**: Bing Webmaster Tools → IndexNow Insights, Yandex.Webmaster → Indexing → Reindex pages. IndexNow is a notification; the share of submitted URLs that are in the index after a few days is the number that says whether the setup works. ## What IndexNow is, and is not A submission tells an engine that a URL changed. Whether and when the page is crawled and indexed is the engine's decision; a `200` from the endpoint means "received", nothing more. Google does not participate. The Bing URL Submission API and Google's Indexing API are different protocols with their own quotas and are not covered by this library. Where to see the result: Bing Webmaster Tools (IndexNow Insights: received URLs, crawl outcome, errors per key) and Yandex.Webmaster (Indexing → Reindex pages, and the crawl statistics). A useful success metric is the share of submitted URLs present in the index after a few days, and the time between a change and the updated snippet. ## Deleted pages: what your site must return An engine that receives a URL fetches it. The response decides what happens to the page in the index: | Situation | Return | Effect | |---|---|---| | Gone for good | `410 Gone` | the fastest removal; `404` works too but is treated as "maybe temporary" | | Temporarily unavailable | `404` (or `503` with `Retry-After` for maintenance) | the page stays indexed for a while | | Moved | `301` to the new URL, and submit **both** URLs (the old one is resolved as a deletion, the new one as an update — the ORM adapters do this on a slug change) | the index follows the redirect | | A "not found" page that answers `200` (soft 404) | do not: fix it to `404`/`410` | the engine keeps a useless page and trusts the site less | | Redirect to the home page | do not: `410` or `301` to the closest equivalent | same as a soft 404 | The library sends the URL of a deleted object exactly once; the site's answer does the rest. The pre-flight of `indexnowkit/verify` never blocks a deletion: a URL that answers `404` or `410` is submitted as is (log line `indexnow verify: {url} gone (HTTP 410), submitted as a deletion`); with `verify.redirect: follow` a `301`/`308` submits both the old and the new URL, a `302`/`303`/`307` only the original. ## What not to submit The engines fetch what you submit, and a URL that is not meant to be indexed costs trust and quota: - pages with `` or an `X-Robots-Tag: noindex` header; - paths that `robots.txt` disallows (the engine cannot fetch them; some count it as an error against the key); - non-canonical URLs: tracking parameters, sort/filter variants, session ids, `http://` next to `https://`, the apex next to `www` — submit the `` target only; - URLs that answer `3xx`, `4xx` or `5xx` (except the deletions above); - drafts, previews, unpublished or access-restricted pages. What protects you today: the URL normalizer accepts only absolute `http(s)` URLs, strips fragments and default ports, and rejects URLs with credentials or control characters; `strict_hosts` keeps foreign hosts out; the `when` guard of a rule keeps drafts out (`when: 'isPublished'`), and a `published → draft` change is submitted as a deletion. What it cannot see: a `noindex` tag, a `robots.txt` rule, a canonical pointing elsewhere. Those are the job of the rule (do not declare a rule on such a model, or guard it with `when`) — and of [`indexnowkit/verify`](../verify/index.md): with `verify.enabled: true` every URL gets one GET before submission and a page with `noindex` (meta or `X-Robots-Tag`), a path `robots.txt` disallows, a page whose canonical is another URL (`non_canonical: skip|replace`), a redirect (`redirect: skip|follow`) or an origin error (`401`/`403`/`5xx`, `origin_error: skip|send`) is skipped with a `Result` of the matching `Reason`; `404`/`410` pass as deletions. Off by default; with `dispatch: sync` the GETs run inside the web request, so use a queue. `check --sample=` / `--sample-class=` reports the same signals for a few pages without submitting anything (warnings at most, so a CI run against an unreachable production stays green). ## Log channel and levels Every message starts with `indexnow: ` and goes to the PSR-3 logger you inject. Framework adapters put it on a dedicated channel — `indexnow` in the Symfony bundle — so `tail -f var/log/prod.indexnow.log` shows the whole story. ### Delivery outcomes (`Client`) | Level | Message | |---|---| | `debug` | `indexnow: {engine} accepted {count} URL(s) for {host}` | | `info` | `indexnow: {engine} accepted {count} URL(s) for {host}, key verification pending (202)` | | `info` | `indexnow: dry-run POST {endpoint} {body}` | | `warning` | `indexnow: skipping {count} URL(s) for unmanaged host {host}: no key configured (add it to "hosts" or set base_url)` | | `warning` | `indexnow: {engine} could not process URLs for {host} (422): URLs do not belong to the host or keyLocation is invalid` | | `warning` | `indexnow: {engine} rate limited (429) for {host}, retry after {retry_after}s` | | `warning` | `indexnow: {engine} server error {status} for {host}` | | `warning` | `indexnow: {engine} transport error for {host}: {error}` | | `error` | `indexnow: {engine} rejected the key for {host} (403). Check that https://{host}/{key}.txt is reachable and contains the key (run the check command of your adapter, e.g. indexnow:check).` | | `error` | `indexnow: {engine} rejected the request as malformed (400): {body}` | | `error` | `indexnow: {engine} unexpected status {status} for {host}: {body}` | | `error` | `indexnow: {engine} HTTP client failure for {host}: {error}` | | `error` | `indexnow: cannot encode {count} URL(s) for {host} as JSON: {error}` | | `error` | `indexnow: throttle failed, sending without rate limiting: {error}` | | `critical` | the 403 message plus `{consecutive} consecutive failures: submissions for this host are not being indexed.` | The 403 escalation is the one line to page on. `logging.forbidden_escalation` is 5 by default: the fifth consecutive 403 for a host is logged once at `critical`, further ones drop back to `warning` so they do not spam, and any non-403 response resets the counter. Since core 0.8 the counter lives in the cache behind `debounce.store` (the adapters pass it to `Client` as the PSR-16 "failure cache"; plain PHP: `IndexNowKit::create(..., failureCache: $cache)`), so PHP-FPM workers and queue workers count together and the fleet writes the `critical` line once per streak: the keys are `403.` and `…_escalated`, kept for an hour after the last 403. With `debounce.store: memory` or `none` the counter stays in the process, where every worker counts its own 403s and pages on its own fifth failure — alert on the `warning` rate of `reason=invalid_key` as well there. A cache that throws is logged once (`failure cache unavailable, counting 403s per process`) and the process counts on. Every other level in these tables is the default of `logging.levels` (`Config::LOG_EVENTS`) and can be raised or lowered per outcome; `logging.max_urls` decides how many URLs a line lists (0 for PII-sensitive logs). Keys are masked everywhere, including inside response bodies and exception messages. ### Configuration (`Adapter\ConfigFactory`, adapters) | Level | Message | |---|---| | `warning` | `indexnow: unknown option(s) in the indexnow configuration: {options}` (dotted keys, the typo check) | | `critical` | `indexnow: invalid configuration, IndexNow is disabled until it is fixed: {error} (run "{check}")` — nothing is sent until the value is fixed | ### Submission pipeline (`Submitter`) | Level | Message | |---|---| | `info` | `indexnow: disabled (enabled: false), dropping {count} URL(s)` | | `warning` | `indexnow: dropping URL: {error}` | | `warning` | `indexnow: debounce store unavailable, submitting without de-duplication: {error}` | | `warning` | `indexnow: debounce store failed after a successful submission, URLs may be re-sent within {ttl}s: {error}` | | `debug` | `indexnow: debounced {count} URL(s) submitted within the last {ttl}s` | | `error` | `indexnow: result listener {listener} failed: {error}` / `indexnow: result event listener failed: {error}` | `disabled` is at `info` on purpose: it is the most common "nothing is happening at all" state, and `debug` is filtered out in most production setups. ### Resolution (`GuardedUrlResolver`, `ObjectChangeHandler`) | Level | Message | |---|---| | `debug` | ``indexnow: {class} rule "{rule}" skipped for {event}: `when` is false`` | | `debug` | ``indexnow: {class} rule "{rule}" ignores this update (fields {changed} vs filter {fields}, or `when` unchanged and false)`` | | `debug` | ``indexnow: no URLs for {class} ({event}): no rule applies (no #[IndexNow], event not subscribed, or `when` is false)`` | | `debug` | `indexnow: {class} does not subscribe to {event}` | | `warning` | `indexnow: #[IndexNow(via: "{via}")] on {class} stops after {max} related objects` | | `error` | `indexnow: invalid #[IndexNow] on {class}: {error}` | | `error` | ``indexnow: cannot evaluate `when` of {class} rule "{rule}": {error}`` | | `error` | `indexnow: cannot classify the change of {class} for rule "{rule}": {error}` | | `error` | `indexnow: cannot resolve URLs for {class} rule "{rule}" ({event}): {error}` | Turn the `indexnow` channel to `debug` while diagnosing: the four debug lines above are the difference between "nothing happened" and "the rule decided not to". ### ORM hooks (`Hook\ObserverHelper`, the observers of every adapter) | Level | Message | |---|---| | `debug` | `indexnow: {source} ({event}) -> {url}` — one line per resolved URL, with the rule that produced it | | `error` | `indexnow: cannot resolve the URLs of {class}: {error}` — the hook went on, the object was not submitted | | `error` | `indexnow: cannot collect {count} URL(s): {error}` | ### Queue workers (`Retry\WorkerOutcome`, the jobs of every adapter) | Level | Message | |---|---| | `info` | `indexnow: {count} URL(s) of job {id} will be retried{delay}{attempt}` — `{delay}` is ` in {n}s` where the job sets the delay (Laravel), `{attempt}` is ` (attempt {n})` where the job knows it | | `error` | `indexnow: giving up on {count} URL(s) of job {id} after {attempt} attempt(s)` (Laravel and yii2-queue; Messenger reports exhausted retries itself) | | `error` | `indexnow: {count} URL(s) of job {id} rejected permanently ({reasons}); run "{check}"` — `{reasons}` lists ` `: `api 403`, `yandex 422` | ### Delivery hand-off | Level | Message | |---|---| | `warning` | `indexnow: {count} collected URL(s) discarded: the unit of work ended without flush() (request end hook not run?)` | | `debug` | `indexnow: discarding {count} staged URL(s), transaction rolled back` / `..., savepoint rolled back` | | `debug` | `indexnow: throttle limit of {per_minute} requests/min reached, waiting {wait_ms} ms` | | `error` | `indexnow: sync dispatch of {count} URL(s) failed, they are lost: {error}` / `indexnow: dispatch of {count} URL(s) failed, they are lost: {error}` | ## Metrics `Result::metricLabels()` returns low-cardinality labels ready for a counter: `status`, `engine`, `reason`, `http_code`, `retryable`. The host is deliberately absent because it is unbounded in multi-tenant setups; add `$result->host` yourself if your cardinality budget allows. ```php $indexNow->submitter->addListener(function (IndexNowKit\Result $result) use ($metrics): void { $metrics->counter('indexnow_results_total', $result->metricLabels())->inc(); $metrics->counter('indexnow_urls_total', $result->metricLabels())->incBy($result->urlCount()); }); ``` A listener that throws is logged and ignored; delivery is never affected. A decorator around `SubmitterInterface` must forward `addListener()`, or listeners registered on the outer object never fire. Alert on: `reason=invalid_key` (the key file broke), a sustained `reason=rate_limited`, `status=failed` with `retryable=false`, and the collector-discard warning above. ## Status and history Two read-only commands come with [`indexnowkit/history`](../history/index.md) (`composer require indexnowkit/history`; `indexnow:history` / `indexnow:status` in Symfony, Laravel and Yii3, `indexnow/history` / `indexnow/status` in Yii2): - **`status`** prints the switches (`enabled`, `dry_run`, environment), the dispatch mode with what the adapter knows about its queue (Messenger transport and bus, Laravel connection and queue, the Yii2 queue component; Yii3 has no queue mode, so it adds nothing there and names the container id of the debounce store instead), the debounce window and store, the engines, the **403 counter of every configured host with its escalation flag** (`Retry\ForbiddenCounter`, the same cache the client counts in), the last successful submission ("3 min ago, 2 URLs, api"), the history size and the core version. `--json` follows `status.schema.json` of the package: alert on `hosts[].escalated` and on `history.error`. Nothing is fetched. - **`history`** lists what the submitter recorded, newest first: `at`, status, reason, engine, HTTP code, URLs (`--host`, `--status=ok|pending|failed|skipped`, `--url` exact after normalization, `--since=2h|3d|2026-09-01`, `--limit`, `--json`). `history --purge` removes what is older than `history.retention_days` (`--purge=30` for 30 days) and prints one line — a cron entry. Both read the `Submission\SubmissionStoreInterface` of the adapter (see [submission-store.md](submission-store.md)): the package's `psr16` ring buffer (one process, development, small sites) or `pdo` table (production; the migration is in the package's `docs/migrations.md`), set by `history.store`, or a store of your own. What is recorded: the normalized URLs (tracking parameters already stripped), host, engine, status, reason, HTTP code, `retryable`, endpoint and the error sentence of the `Result` — never a response body, a header or the key. `check` adds `history.store` (the configured store, or an error with the migration hint when the table is missing) and `history.records` (`history: 1 240 records, last 3 min ago`). ## Monitoring rules Four rules cover what goes wrong in production; the first two page, the other two open a ticket. | # | Signal | Threshold | Meaning and action | |---|---|---|---| | 1 | `critical` on the `indexnow` channel | any | the key file broke (403 ×5) or the configuration is invalid and IndexNow is off: run `check`, fix, redeploy | | 2 | results with `status=failed`, `retryable=false` (403, 422, 400) | > 0 in 15 min | permanent rejections: the key file, URLs of a foreign host, or a bug — `explain` one of the URLs | | 3 | results with `reason=rate_limited` | sustained for 10 min | the engine throttles you: lower `throttle.max_requests_per_minute`, raise `batch.max_urls` usage, or wait; retries follow `Retry-After` | | 4 | `warning: … collected URL(s) discarded` | any | a request or job ended without `flush()`: the runtime skipped the terminate hook (early `exit()`, fatal error, long-running runtime) — prefer a queued dispatch there | Everything else the library logs at `warning` is per request and self-healing (a cache blip, a 5xx that the queue retries): count it, do not page on it. A `debug`-level channel in production is fine volume-wise only with `logging.max_urls: 0`. **Sentry filter.** The library logs at `warning` for outcomes the queue retries; forwarding every one of them to Sentry turns a rate-limited hour into hundreds of events. Keep `error` and above from the `indexnow` channel, drop the rest: ```php // sentry.php / config/sentry.php — keep errors, drop the per-request warnings of the library 'before_send' => static function (\Sentry\Event $event): ?\Sentry\Event { $level = (string) $event->getLevel(); if ($event->getLogger() === 'indexnow' && !\in_array($level, ['error', 'fatal'], true)) { return null; } return $event; }, ``` (Symfony: the channel name is `logging.channel`, default `indexnow`; Laravel: the log channel of `indexnow.logging.channel`; Yii2 and Yii3: the category of `logging.category`, default `indexnow` — Yii's Sentry targets pass it as the logger.) ## "My URL was not submitted" Walk it in this order. Each step names the reason or log line that proves it. 1. **Is IndexNow on?** `enabled: false` yields `skipped` / `disabled` and one `info` line per call. 2. **Is it dry-run?** `dry_run` yields `skipped` / `dry_run` and an `info` line with the full body. Outside production a missing key turns this on automatically — that is the intended dev behaviour and a bug in prod. `Checker` reports it as an error when `environment` says production. 3. **Did the rule fire at all?** With an ORM, the `debug` lines above say whether a rule was skipped by `when`, by `events`, or by `fields`. No lines at all means no rules were found: check that the class really carries `#[IndexNow]` and that nothing logged `invalid #[IndexNow] on {class}`. 4. **Did the URL survive normalization?** `warning: indexnow: dropping URL` and `skipped` / `invalid_url`. The usual cause is a relative URL with no `base_url`, in a console command or a worker. 5. **Is there a key for that host?** `warning: skipping ... unmanaged host` and `skipped` / `no_key`. With `strict_hosts` this fires for every host outside `base_url` and the `hosts` map. 6. **Was it debounced?** `skipped` / `debounced`. The same URL is not re-sent within `debounce.per_url`. The debug line reports the count. 7. **Did the engine reject it?** `failed` with reason `invalid_key` (403, key file), `unprocessable` (422, URLs on another host or a bad `keyLocation`), `invalid_request` (400, please report), `rate_limited` or `server_error`. 8. **Did anything get collected but never flushed?** See the next section. ## The collector and units of work `Collector` buffers normalized URLs and is drained once by `IndexNowKit::flush()`. Nothing sends until then. `Collector::reset()` empties the buffer **without delivering**, for long-running runtimes that recycle services between requests. It logs at `warning` when the buffer was not empty. That line means a unit of work ended without a flush and those URLs are gone; it is nearly always the smoking gun for "the entity saved and nothing arrived". Under Symfony, `flush()` runs on `kernel.terminate`, `console.terminate` and `WorkerMessageHandledEvent`. `kernel.terminate` fires only when the SAPI lets it: an early `exit()`, a fatal error before termination, or a reverse-proxy setup that never releases the request can skip it. Under Swoole, RoadRunner or FrankenPHP the behaviour depends on the runtime bridge. In those environments prefer a queue-backed dispatch, where the batch is durably enqueued before the worker moves on, and treat the collector-discard warning as a monitored signal. Long-running custom commands should call `flush()` periodically instead of accumulating for the life of the process. ## Debounce and cache outages The debounce store fails **open**. If `filterRecent()` throws, the submission proceeds without deduplication and logs `debounce store unavailable`; if `markSubmitted()` throws afterwards, the window is not recorded and the URLs may be re-sent within the TTL. Both are warnings, one per `submit()` call, so the noise is bounded by request volume rather than URL volume. The visible symptom of a Redis blip is therefore a burst of duplicate submissions, not lost ones. That is the right trade: a missed submission leaves stale content in the index, a duplicate costs one request. `MemoryDebounceStore` is per process and bounded to 50 000 entries. It is right for CLI runs, tests and single workers; a web application should use `Psr16DebounceStore` on a shared cache so the window survives across processes. ## Throttling in web requests versus workers `TokenBucket` blocks with `usleep()` and counts one token per outgoing HTTP request, per process. Inside a web request it only engages when a single request produces more batches than the limit, so keep `throttle.max_requests_per_minute` comfortably above that, or install `NullThrottle` there and rate-limit in the worker. A throttle that throws never blocks delivery: the request goes out unlimited and an `error` is logged. ## Key rotation Rotating a key breaks submissions until the new key file is reachable, because engines answer 403 for a key whose file they cannot verify. 1. Serve the **new** key file first, alongside the old one if your setup allows it. With the shipped key file route, `previous_key` (`INDEXNOW_PREVIOUS_KEY`) does exactly that: the route answers for both keys, submissions use the new one only. 2. Keep `Cache-Control` short. `KeyFileResponder::DEFAULT_MAX_AGE` is 300 seconds for exactly this reason: a CDN holding the old file for a day means a day of 403s. 3. Switch the configured key. `key:generate --write-env --force` does the whole step in the env file: the new key goes to `INDEXNOW_KEY`, the old one to `INDEXNOW_PREVIOUS_KEY`. It refuses to rotate while `INDEXNOW_PREVIOUS_KEY` still holds the key of an earlier rotation (engines may still verify against it): remove the variable first, or pass `--no-previous` to drop the old key on purpose, or `--yes` to overwrite it. 4. Run the check command. `Checker` fetches every key file over HTTP and compares the body, its `Content-Type` and its `Cache-Control`/`Age` against `key_file.cache_max_age`, and `robots.txt`; `--live` sends a real probe to every endpoint even when `dry_run` is on. With `previous_key` set, the old key file is fetched too: `previous key file OK … rotation window open` (`key_file.previous`) means both keys are served; a warning means the old file is already gone while engines may still verify against it. 5. Watch for the 403 escalation. Five consecutive failures for a host means nothing is being indexed. 6. Remove `previous_key` once `check --live` is green for every host: the line goes away with it. If the key file cannot live at `/{key}.txt`, set `key_location` to its absolute URL on the same host. A `key_location` on a different host is rejected at configuration time, because engines answer 422 for it. Behind a proxy or a CDN on a PSR-15 stack, put `Key\KeyFileRequestHandler` in the middleware pipeline **before** the router (and before any authentication or maintenance-mode middleware): the key file is then served whatever the application does with the rest of its routes, and every other request goes on untouched. The key travels in the JSON body of every submission and in the key file, nowhere else: the library never uses the GET form of the protocol (`?url=…&key=…`), so the key does not end up in access logs, proxy logs or referrers. Logs and exception messages of the library mask it to four characters. # Retries, queues and bulk submissions The core never retries inside a web request. `submit()` returns one `Result` per endpoint × host × batch, and the ones worth trying again carry `retryable: true` (429, 5xx, network failures and unexpected client errors). What you do with them is a deployment decision, not a library one. ## RetryPolicy `Retry\RetryPolicy` decides how long to wait, identically in every adapter. ```php use IndexNowKit\Retry\RetryPolicy; $policy = new RetryPolicy( maxAttempts: 3, // total attempts including the first baseDelay: 60, // seconds before the second attempt after a 429 without Retry-After multiplier: 2.0, maxDelay: 3600, serverErrorDelay: 5, // seconds before the second attempt after 5xx or a network failure ); $delay = $policy->delayAfter($results, $attempt); // null = stop ``` `delayAfter()` returns `null` when the attempt number has reached `maxAttempts` or nothing in the batch is retryable. Otherwise it honours the largest `Retry-After` any result reported, and falls back to `base × multiplier^(attempt-1)`, clamped to `maxDelay`. The base is 60 seconds after a 429, because the engine explicitly asked you to slow down, and 5 seconds after a 5xx or a network blip, which is usually transient. ## In-process retries `Retry\RetryingSubmitter` decorates any `SubmitterInterface` and re-submits the retryable URLs in place. The delay is a blocking `sleep()`, so this belongs in CLI commands, cron jobs and queue workers, never in a web request. ```php use IndexNowKit\Retry\{RetryPolicy, RetryingSubmitter}; $submitter = new RetryingSubmitter($indexNow->submitter, new RetryPolicy(maxAttempts: 3)); $results = $submitter->submit($urls); ``` The returned list holds the last outcome for each URL: results that were retried replace their earlier failure, and results that were never retryable are carried through unchanged. Pass a `$sleeper` callable as the fourth argument to make the retries instant in tests. `RetryingSubmitter` forwards `addListener()` to the inner submitter, so profilers and metrics keep working. Any decorator you write must do the same, or every listener registered on the outer object is silently dropped. ## Queue workers Enqueue the URL list, submit in the worker, re-enqueue what came back retryable. ```php // producer $indexNow->collect($urls); // during the unit of work $indexNow->flush(); // hands the batch to the DispatcherInterface // dispatcher, enqueuing instead of sending use IndexNowKit\Dispatch\CallableDispatcher; $dispatcher = new CallableDispatcher(fn (array $urls) => $queue->push(new SubmitUrls($urls, attempt: 1)), $logger); // worker $results = $indexNow->submit($message->urls); $retry = IndexNowKit\Result::retryableUrls($results); $delay = (new RetryPolicy())->delayAfter($results, $message->attempt); if ($retry !== [] && $delay !== null) { $queue->later($delay, new SubmitUrls($retry, attempt: $message->attempt + 1)); } ``` `Result::retryableUrls()` deduplicates and keeps first-occurrence order. `Result::allUrls()` and `Result::urlsWhere($results, $predicate)` cover the other selections (`Result::urlsOf()`, deprecated since 0.2.0, is gone in 0.4). A worker has no request context, so `base_url` must be configured or every relative URL is dropped as invalid. A dispatcher must never throw into user code: `SyncDispatcher` and `CallableDispatcher` log and swallow. ## Which failures are worth retrying | Outcome | Retry | Why | |---|---|---| | 429 `rate_limited` | yes, after `Retry-After` | the engine will accept it later | | 5xx `server_error` | yes | transient on the engine's side | | network / timeout `transport` | yes | transient on yours | | `unexpected` | check `retryable` | an ill-behaved HTTP client is retryable; a status no engine should return is not | | 403 `invalid_key` | **no** | the key file is wrong; retrying changes nothing, fix it and resubmit | | 422 `unprocessable` | **no** | the URLs do not belong to the host, or `keyLocation` is invalid | | 400 `invalid_request` | **no** | a bug in the library; please report it | | `skipped` (any reason) | **no** | nothing was sent on purpose | ## Bulk imports and migrations A migration that touches 50 000 rows is the one case where the defaults are wrong. - **Do not call `submit()` for 50 000 URLs inside a web request.** Chunk into `Config::MAX_BATCH_URLS`-sized submissions from a CLI command or a worker, with a `RetryingSubmitter` around them. - **Prefer the site's own URL list.** The add-on package in the README family table streams it and filters by modification date, so re-announcing yesterday's changes is one command, not a script. - **Watch the debounce store.** `MemoryDebounceStore` is bounded to 50 000 entries and evicts expired entries first, then the oldest. A run larger than that which also re-touches earlier URLs silently gets a shorter effective debounce window. Use `Psr16DebounceStore` on a shared cache for long runs. - **Throttle in the worker, not in the request.** `TokenBucket` blocks with `usleep()` and counts per process. In a web request keep `throttle.max_requests_per_minute` well above the number of batches one request can produce, or use `NullThrottle` there and rate-limit in the queue instead. - **Rule fan-out is smaller than it looks.** Four rules on a class plus `via: 'category'` means one imported row touches six URLs, but the collector deduplicates within the unit of work and `debounce.per_url` deduplicates across them: a homepage rule costs one submission per debounce window, not one per row. ## Collecting and flushing `Collector` is the per-unit-of-work buffer: `add()`, `all()`, `count()`, `drain()`, `reset()`. `IndexNowKit::flush()` drains it into the dispatcher and does nothing when it is empty. Call it once at the end of the HTTP request, the console command or the queue message. `reset()` empties the buffer **without** delivering, for long-running runtimes that recycle services between requests. It logs a warning when the buffer was not empty, because that means a unit of work ended without a flush and the URLs are gone. Alert on that line. Replace `CollectorInterface` when you need a durable outbox instead. # Testing `IndexNowKit\Testing` is part of the published package, not a dev-only helper: application and adapter test suites are expected to use it. Four doubles, no framework, no HTTP. | Double | Replaces | Gives you | |---|---|---| | `FakeTransport` | `Http\TransportInterface` | recorded POSTs with the decoded body, queued responses and failures | | `ArrayLogger` | `Psr\Log\LoggerInterface` | every record, plus `messages()` with the context interpolated | | `FrozenClock` | `Psr\Clock\ClockInterface` | a clock that only moves when you call `advance()` | | `RecordingDispatcher` | `Dispatch\DispatcherInterface` | the batches handed over, without sending them | ## Asserting what would be submitted ```php use IndexNowKit\{Config, IndexNowKit}; use IndexNowKit\Debounce\NullDebounceStore; use IndexNowKit\Testing\{ArrayLogger, FakeTransport}; $transport = new FakeTransport(); $logger = new ArrayLogger(); $indexNow = IndexNowKit::create( new Config(key: 'test-key-1234', baseUrl: 'https://www.example.com'), transport: $transport, logger: $logger, debounce: new NullDebounceStore(), ); $results = $indexNow->submit(['/posts/hello', '/posts/hello', '/about']); self::assertCount(1, $transport->posts); self::assertSame('https://api.indexnow.org/indexnow', $transport->posts[0]['url']); self::assertSame( ['https://www.example.com/posts/hello', 'https://www.example.com/about'], $transport->posts[0]['body']['urlList'], ); self::assertTrue($results[0]->isSuccess()); ``` Every entry of `$transport->posts` is `['url' => ..., 'json' => ..., 'headers' => ..., 'body' => ...]`, where `body` is the decoded payload, so you assert on `host`, `key`, `keyLocation` and `urlList` directly. `NullDebounceStore` keeps a test from depending on the debounce window. Use `MemoryDebounceStore` with a `FrozenClock` instead when the window is what you are testing. ## Entities and rules ```php $urls = $indexNow->urlsFor($post, IndexNowKit\Event::Updated); self::assertSame(['https://www.example.com/posts/hello'], $urls); foreach ($indexNow->explain($post, IndexNowKit\Event::Updated) as $resolved) { // $resolved->rule, ->class, ->event, ->locale, ->url, ->source() } ``` `urlsFor()` and `explain()` never throw, so a test that expects a broken attribute to be reported asserts on the log instead: ```php self::assertStringContainsString( 'invalid #[IndexNow] on ' . Broken::class, implode("\n", $logger->messages('error')), ); ``` ## Engine responses and failures `willRespond()` queues responses in order; anything beyond the queue gets the constructor default. Queue a `Throwable` to simulate a network failure. ```php use IndexNowKit\Http\Response; use IndexNowKit\Testing\FakeTransport; $transport = (new FakeTransport())->willRespond( new Response(429, '', 30), // rate limited, Retry-After: 30 new Response(200), ); $results = $indexNow->submit(['/a']); self::assertTrue($results[0]->retryable); self::assertSame(30, $results[0]->retryAfter); self::assertSame(IndexNowKit\Reason::RateLimited, $results[0]->reason); $transport->willRespond(FakeTransport::failing('connection refused')); // TransportException on the next POST ``` `FakeTransport::failing()` returns a ready-made `TransportException`; `Response::parseRetryAfter()` is what a real transport uses to turn the header into seconds, and takes a `$now` argument so HTTP-date values are testable. ## Retries without waiting `RetryingSubmitter` takes a sleeper, so a retry test runs instantly and can assert on the delay. Continuing the queue above (429 with `Retry-After: 30`, then 200): ```php use IndexNowKit\Retry\{RetryPolicy, RetryingSubmitter}; $slept = []; $submitter = new RetryingSubmitter( $indexNow->submitter, new RetryPolicy(maxAttempts: 3, baseDelay: 60), $logger, static function (int $seconds) use (&$slept): void { $slept[] = $seconds; }, ); $submitter->submit(['/a']); self::assertSame([30], $slept); // Retry-After won over the exponential base ``` ## Debounce windows ```php use IndexNowKit\Debounce\MemoryDebounceStore; use IndexNowKit\Testing\FrozenClock; $clock = new FrozenClock('2026-01-01 00:00:00'); $indexNow = IndexNowKit::create($config, transport: $transport, debounce: new MemoryDebounceStore($clock)); $indexNow->submit(['/a']); $indexNow->submit(['/a']); self::assertCount(1, $transport->posts); // second call debounced $clock->advance(601); $indexNow->submit(['/a']); self::assertCount(2, $transport->posts); ``` `TokenBucket` takes the same clock plus its own sleeper, so throttling is testable the same way. ## Collecting without sending ```php use IndexNowKit\Testing\RecordingDispatcher; $dispatcher = new RecordingDispatcher(); $indexNow = IndexNowKit::create($config, transport: $transport, dispatcher: $dispatcher); $indexNow->collect(['/a', '/b']); self::assertSame(2, $indexNow->collector->count()); $indexNow->flush(); self::assertSame(['https://www.example.com/a', 'https://www.example.com/b'], $dispatcher->urls()); self::assertCount(1, $dispatcher->batches); self::assertTrue($indexNow->collector->isEmpty()); ``` This is the right double for adapter tests: it proves the unit-of-work hook fired without involving HTTP at all. ## The key file ```php $transport->onGet('https://www.example.com/test-key-1234.txt', new Response(200, 'test-key-1234')); $report = (new IndexNowKit\Check\Checker($config, $indexNow->keys, $transport))->run(); self::assertFalse($report->hasErrors()); ``` Unregistered GET URLs answer `404`, which is what a "key file missing" test wants. ## Dry run `dry_run` exercises the whole pipeline — normalization, deduplication, grouping, key lookup — and stops before the POST. Results come back as `skipped` with reason `dry_run`, and the body is in the `info` log line. ```php $indexNow = IndexNowKit::create($config->with(dryRun: true), transport: $transport); self::assertSame([], $transport->posts); ``` Prefer it in application test suites where you care that a change *would* have been announced; prefer `FakeTransport` where you care about the exact payload. ## Assertions for an adapter's HTTP and command tests The conformance scenarios H01–H06 are the same in every framework, only the way a response or a command output is captured differs. Two static helpers of [`indexnowkit/testing`](../testing/index.md) (`composer require --dev indexnowkit/testing`) hold the assertions, so an adapter test parses its framework's objects and asserts once: ```php use IndexNowKit\Testing\Conformance\CheckOutputAssertions; use IndexNowKit\Testing\Conformance\KeyFileAssertions; // H01: 200, text/plain, the key as the body, Cache-Control with public and max-age, Vary: Host exactly when the // body depends on the host — a hosts map or strict_hosts, which is what Config::keyFileHeaders() decides KeyFileAssertions::assertKeyFileResponse($response->getStatusCode(), $response->headers->all(), $response->getContent(), $key, maxAge: 300, expectVaryHost: true); // H02/H03: an unknown key, another host's key, key_file.enabled: false KeyFileAssertions::assertNotServed($response->getStatusCode()); // H04/H05: the check command CheckOutputAssertions::assertExitCode(0, $exitCode, $output); // the output is the failure message CheckOutputAssertions::assertReady($output, 'www.example.com'); // ": key file OK" and the closing line CheckOutputAssertions::assertKeyFileHint($output, 403); // the status and the hint about what the engines do ``` `Cache-Control` is compared by directive (frameworks order them differently), header names in any case, values as a string or a list. ## Conformance kits for adapters Two abstract PHPUnit cases of `indexnowkit/testing` turn docs/spec/03 into runnable scenarios against *your* wiring (the package is `require-dev`; the core itself ships no PHPUnit code): - `Testing\Conformance\CoreConformanceTestCase` (C01, C03, C04, C06, C09–C12, C14, C19, C20): return the facade your container built and the `FakeTransport` it is wired to; optionally a second configured host for C04. - `Testing\Conformance\OrmConformanceTestCase` (A01–A21, plus A05b/A05c): implement the driver — the transaction verbs of your data layer (`begin()`, `commit()`, `rollback()`), the end of a unit of work (`flush()`, `collectedCount()`), and fixtures with fixed rule shapes (`createPost()`, `createMultiPost()`, `createCategorizedPost()`, `createTag()`, `attachTag()`, `bulkUpdateTitle()`, …). The docblock of the class lists the rules every fixture must carry; the URL conventions (`postUrl()`, `ampUrl()`, `categoryUrl()`, `homeUrl()`) are overridable. `indexnowkit/doctrine` (`tests/OrmConformanceTest.php`) and `indexnowkit/laravel` (`tests/Conformance/`) are the reference drivers. A scenario that does not apply to your framework is documented in your README, not skipped silently. ## Notes for adapter authors - Assert on rules and events through `ObjectChangeHandler::createdEvents()`, `updatedEvents()` and `deletedEvents()` before resolving, so an ORM test does not need URLs to verify classification. - `IndexNowKit::create()` rejects combining a custom `submitter:` with `transport:`, `debounce:`, `throttle:` or `normalizer:`, because a custom submitter builds its own pipeline. Pass those to your submitter instead. - `indexnowkit/testing` ships a mock IndexNow server for end-to-end runs through a real PSR-18 client: `php -S 127.0.0.1:8089 vendor/indexnowkit/testing/resources/mock-server/router.php`, with scenarios selected by an `X-Mock-Scenario` header (`ok200`, `pending202`, `forbidden403`, `ratelimit429`, …), `MOCK_KEYS` for the key files it serves and a request log at `GET /_mock/requests`. The core's own `Psr18TransportTest` runs against a private copy of the same router (`tests/Support/mock-server/`), because the core cannot depend on `testing`. ## How the family itself is verified Three floors, one rule each, all in the monorepo CI (`.github/workflows/ci.yml`, `taint.yml`): | Floor | Where | Tool | Rule | |---|---|---|---| | Line coverage | `packages//tests/coverage-floor.txt`, every package | PHPUnit + pcov, `bin/coverage-floor` | the coverage measured when the floor was set; raising it is a normal commit, lowering it a separate commit with the reason | | Mutation score (MSI) | `packages//tests/msi-floor.txt` — core, verify, sitemap, history, console | [Infection](https://infection.github.io) over the whole `src`, `bin/mutation ` | the same ratchet; the `mutation` job is non-blocking until every floor is a CI measurement that held three weekly runs, the `mutation / changed lines` job of a pull request mutates only the lines it changes | | Taint | `packages//psalm.xml` — the same five packages | [Psalm](https://psalm.dev/docs/security_analysis/) `--taint-analysis`, `bin/taint ` | blocking; a flow that is the feature (a file path from the command line) is suppressed in `psalm.xml` with the reason next to it | Coverage says a line ran; the mutation score says a test would notice if the line were wrong (`<` for `<=`, a dropped `return`, an off-by-one in a limit), which is the difference between "covered" and "checked". Log texts and exception messages are not API ([bc.md](bc.md)), so the mutants that only reword them are ignored in `infection.json5` by regex, not by annotations in the code. Infection and Psalm are tools of the monorepo (`tools/infection`, `tools/psalm`, with their locks), not dev dependencies of the packages: Infection needs PHP 8.3 while the packages support 8.2, and Psalm is used for the taint analysis only — phpstan level 9 with strict rules is the type checker. A library has no taint source of its own, so each package in the taint matrix carries `tests/Taint/entrypoints.php`: its public API called with request data (`$_GET`, `$_POST`, `php://input`), the entry points Psalm follows into the sinks it knows (`PDO`, `file_put_contents()`, `header()`, `echo`). What Psalm cannot see here, and the tests cover instead: a URL that went through `Url\UrlNormalizer` is clean to Psalm (`parse_url()` ends the flow), so the SSRF class — redirects, `canonical`, nested sitemaps — is proven by the allow-list tests of verify and sitemap, not by the taint job. The framework adapters are outside the matrix: their inputs are the frameworks' request objects, which Psalm does not treat as sources without a plugin per framework. # Writing an adapter For someone who has never read this package's source and wants a working framework adapter by the end of the day. Every section names the core types involved and the conformance scenarios from [docs/spec/03-conformance.md](https://github.com/indexnowkit/spec/blob/main/03-conformance.md) it satisfies. ## 1. Is an adapter the right thing? You do not need a package to use IndexNow. `IndexNowKit::create()` plus a `CallableUrlResolver` covers a single application: ```php $locator = new ArrayResolverLocator(['post' => fn (Post $p) => '/posts/' . $p->slug]); $indexNow = IndexNowKit::create($config, resolver: new AttributeUrlResolver(new AttributeReader(), ParamExtractor::plain(), null, $locator)); ``` An adapter is warranted when other people's applications should get the same behaviour without wiring it. Three shapes exist, and most packages are one of them: - **ORM hook** — the framework has a unit of work and a commit boundary (`indexnowkit/doctrine`). - **CMS hook** — models cannot carry attributes; rules are registered at runtime (WordPress post types, Drupal). - **Framework glue** — container wiring, config, commands, a key-file route (`indexnowkit/symfony-bundle`). ## 2. The 20-minute adapter Everything a minimal adapter needs, in one file, on layer 2 of the kit: `Adapter\ServicesBuilder` describes the graph (your container's pieces as closures, everything else from the core's factories), `Adapter\Services` builds it lazily, `Hook\ObserverHelper` is the never-throwing part of the model hooks. It passes A01, A04, A07 and H01–H03; the core keeps this exact class under test (`tests/Unit/Adapter/TwentyMinuteAdapterTest.php`). ```php final class IndexNowIntegration { public readonly Services $services; private readonly ObserverHelper $hooks; /** @param array $frameworkConfig the raw config array, your own blocks included */ public function __construct(array $frameworkConfig, ?string $environment, LoggerInterface $logger, ?RouteUrlResolverInterface $router = null) { // Never throws: an invalid value is one critical log line and a disabled Config until it is fixed. $config = (new ConfigFactory(ownedOptions: ['myfw.route_prefix'], checkCommand: 'myfw indexnow:check'))->load($frameworkConfig, $environment, $logger); $builder = (new ServicesBuilder($config, $logger)) ->httpClientLocator(fn (string $id): object => $this->service($id) ?? throw new RuntimeException($id)) ->debounceStore(fn (Services $s): DebounceStoreInterface => DebounceStoreFactory::fromConfig($s->config, fn (string $id) => $this->cache($id))) ->resolverLocator(new ArrayResolverLocator([], locate: fn (string $id) => $this->service($id), hint: 'a service id')); if ($router !== null) { $builder->router($router); } $this->services = $builder->build(); // no IO: nothing is built before it is used $this->hooks = ObserverHelper::forChanges($this->services->changes(), fn(array $urls) => $this->services->kit()->collect($urls), $logger); // the hook resolves without building the client } /** Model save hook. */ public function onSaved(object $model, array $changedFields): void { $urls = $this->hooks->guard($model, static fn (ObjectChangeHandler $changes): array => $changes->updated($model, $changedFields)); $this->hooks->deliver($urls ?? []); } /** Model delete hook, before the row disappears: resolve now, deliver once it is gone. */ public function onDeleting(object $model): void { $urls = $this->hooks->guard($model, static fn (ObjectChangeHandler $changes): array => $changes->deleted($model)); $this->hooks->rememberDeletion($model, $urls ?? []); } public function onDeleted(object $model): void { $this->hooks->deliver($this->hooks->takeDeletion($model) ?? []); } /** End of the unit of work: after the response was sent, if the platform allows it. Never throws. */ public function onShutdown(): void { $this->services->flushIfCollected(); } /** GET /{key}.txt */ public function keyFileResponse(string $path, string $host): ?array { $body = $this->services->keyFileResponder()->bodyForPath($path, $host); return $body === null ? null : [$body, $this->services->config->keyFileHeaders()]; } /** How your framework resolves `debounce.store` to a PSR-16 cache and `#[IndexNow(resolver: ...)]` / `http.client` ids to services. */ private function cache(string $id): CacheInterface { /* your container */ } private function service(string $id): ?object { /* your container; null = unknown */ } } ``` Everything below is refinement of those six methods. What the builder did not get comes from the factories of layer 1: `Http\TransportFactory::lazy()` (`http.client`, through your locator), `Debounce\DebounceStoreFactory`, `Dispatch\DispatcherFactory` (`dispatch`; give `queueFactory()` a closure for your queue), `fromConfig()` on `Collector`, `TokenBucket`, `AttributeUrlResolver` and `KeyFileResponder`. Override any node with `transport()`, `submitter()`, `dispatcher()`, `urlResolver()`, … and every dependent node uses the replacement; `build()` throws `ConfigurationException` for what is statically wrong (a `debounce.store` id without a store, a queue mode without a queue). `Services` also gives you `checker()` (add your lines with `checks()`), `submitterFactory()` for the commands, `rules()` for rules registered at runtime, and `hasCollected()`/`flushIfCollected()` for the request-end hook. The parity between the two layers is a test in the core (`ServicesParityTest`). Three accessors exist because two adapters wrote the same workaround before them. `router()` and `resolverLocator()` return `null` when the graph has no such node, which is right for the core and wrong for a framework adapter that always sets one: writing `?? throw` after every call is a dead branch with a message nobody reads. Use **`requireRouter()`** and **`requireResolverLocator()`** instead — same node, a `ConfigurationException` naming the missing node when it is really absent. And the `events` closure **may return `null`**, like the nullable nodes: an adapter whose container may or may not hold a `Psr\EventDispatcher\EventDispatcherInterface` answers that question lazily inside the closure rather than asking the container during `build()`, which is meant to do no IO at all. `Hook\ObserverHelper` has two named constructors for the same reason: `forChanges($changes, $sink, $logger)` for a hook that must not build the client, and `forKit($kit, $logger)` for the plain case, so nobody has to work out what the union-typed constructor wants. A container that describes services (Symfony, Laravel) stays on layer 1 and calls the same factories service by service: its service ids and bindings are its public API, and a builder would hide them. `IndexNowKit::create()` is the plain-PHP form of the same graph. ### Optional packages `indexnowkit/sitemap`, `indexnowkit/verify` and `indexnowkit/history` are `suggest`ed, not required: an adapter must work without them and say so where the user looks. The recipe, the same in the three reference adapters (shown for sitemap; verify uses `PageSignals::class` as the marker and decorates the submitter and the command factory when `verify.enabled`, history uses `HistoryConfig::class` and puts its store into the submission-store slot when `history.store` is set — an interface cannot be a marker, `class_exists()` is false for it): - **One predicate per adapter, `Adapter\OptionalPackage`**: `OptionalPackage::sitemap($installed)` (`verify()`, `history()`: the Composer name, the marker class and the feature word live in the core, the markers as strings) — `installed()` is `class_exists()` of the marker unless the adapter passes an override (`null` = detect; the bundle's `sitemapInstalled` constructor argument, a Laravel container binding under `IndexNowKitServiceProvider::SITEMAP_PACKAGE`, the Yii2 component's `sitemapInstalled` property, the Yii3 service's `sitemapInstalled` argument). No statics: the override travels with the adapter's own configuration. `notInstalledMessage()`, `checkLine()`, `checkLevel()` and `check()` are the three texts below, written once. **Ask the core, not the package**: `Sitemap\Adapter\SitemapServices::package()` returns the same object but lives in `indexnowkit/sitemap` — an adapter that called it to find out whether the package is installed booted fine in its test suite (the package in `require-dev`, `installed: false` passed by hand) and was a `Class not found` in an application without the package. The CI job `optional-packages-absent` removes the three packages and boots every adapter with detection; `Testing\Conformance\OptionalPackageAssertions::assertDetected()` is the assertion of that test. - **Separate classes behind it**: every file with a `use IndexNowKit\Sitemap\*` is instantiated only when the predicate holds (`\Console\SitemapCommand`, and whatever registers the reader, the spool check and the runner). A `::class` constant on an absent class is safe; `SitemapConfig::OPTIONS`, `SitemapReader::MAX_*` or `Sitemap\Console\Definitions` in a file that is loaded without the package are a fatal. - **The package wires itself**: `Sitemap\Adapter\SitemapServices`, `Verify\Adapter\VerifyServices` and `History\Adapter\HistoryServices` hold what every adapter needs from the package — `options()`, `config()`, the reader / transport / robots cache / stores, the decorators, the check lines with their texts, the runners — as plain static functions over the pieces, plus `*For()` twins over `Adapter\Services` for a runtime graph. Call them; do not copy the constructions or the texts. What stays in the adapter is what the framework decides: where the block comes from, how a cache, a connection or `http.client` is looked up, the queue facts of `status`, the sample check over the ORM, when a request is a web request. - **A stub command with the same name** (`SitemapNotInstalledCommand`, or the Yii action) that ignores its arguments, prints `indexnowkit/sitemap is not installed: composer require indexnowkit/sitemap` and exits `ExitCode::FAILURE`: a cron that ran `sitemap` before the package went optional gets a sentence, not "command not found". - **`OptionalPackage::check($block, $defaults)`** in the checker (a `Check\StaticCheck`): `sitemap: not installed (composer require indexnowkit/sitemap)` at level ok when the block is absent or equal to the defaults the adapter ships, or `sitemap: not installed, the sitemap block in the configuration is ignored (composer require indexnowkit/sitemap)` at level warning when the application configured a block nothing reads. That line is the only place the absence is mentioned: no log line at boot or on a request. - **`Check\SampleGateCheck` over `Check\SampleOptions`**, both in the core, for `check --sample` / `--sample-class`. `verify` is the one optional package with a command option in front of it, so the check that decides what `--sample` means has to load **without** the package — which is why the gate lives in the core and not in verify, and why every adapter used to carry a byte-identical copy of it together with its two user-facing sentences. Do not copy it again. `SampleOptions` is one mutable holder per graph that the `check` command fills with what it parsed (`urls`, `classes`, and the `sampler` closure that turns a class into URLs through your record loader — the only part that is yours). `SampleGateCheck::withPackage($options, $factory)` hands them to the package's `Verify\Check\SampleCheck`; `::withoutPackage($options, $package, $block, $defaults)` writes the "not installed" line of the predicate with ` — pre-flight checks off`, or an error `check --sample needs indexnowkit/verify` when a sample was given anyway. Every line it writes carries the code `verify.installed` (`SampleGateCheck::CODE`), never `verify.sample` — that code exists only while the package does ([check-codes.md](check-codes.md)). - **`SitemapConfig::loadOrDisabled($block, $logger, $checkCommand)`** (from `indexnowkit/sitemap`) builds the sitemap configuration at runtime: an invalid block is one `critical` line naming the error and your check command, and a disabled configuration — nothing throws from the container. - **`ConfigFactory(ignoreBlocks: ['sitemap'])`** without the package (and `...SitemapConfig::OPTIONS` in `ownedOptions` with it), so a configuration written for the package does not warn as "unknown option" once the package is gone. `ownedOptions` stays dotted: a bare `sitemap` in it would hide every typo inside the block. ## 3. The component graph ``` Transport -> Client -> Submitter -> Collector + Dispatcher -> IndexNowKit ^ ^ KeyProvider UrlResolver <- AttributeReader ``` `IndexNowKit::create()` builds all of it with sensible defaults; every argument is optional and named, and parameter **names** are part of the compatibility promise, so always pass them by name. In a container, build the same graph service by service — that is exactly what the Symfony bundle does, and nothing in the library requires the facade. Two rules when substituting pieces. A custom `submitter:` brings its own pipeline, so combining it with `transport:`, `debounce:`, `throttle:` or `normalizer:` is rejected instead of silently ignored. And wrap the transport in `Http\LazyTransport` so a request that submits nothing never pays for client discovery and never fails on a missing PSR-18 client: ```php $transport = new LazyTransport(fn () => Psr18Transport::discover(timeout: $config->httpTimeout)); ``` ## 4. Configuration Map your framework's config file onto `Config::fromArray()`. `Config::OPTIONS` is the canonical list of keys the core owns; `Config::unknownOptions($data, $allowed)` reports typos in the rest, so `debounce.per_urls` does not pass silently. Strip your own blocks before handing the array over. `dispatch` is a free identifier the core validates and reports but never acts on — the adapter decides what `sync`, `queue` or `messenger` mean. Feed `environment` from your framework's environment name to get the non-production dry-run safety net. Full details in [configuration.md](configuration.md). If your config can only be validated at runtime (environment placeholders), do not let a bad value throw from a save hook. `Adapter\ConfigFactory` is that path, declared once per adapter: ```php $factory = new ConfigFactory( ownedOptions: ['queue.connection', 'queue.delay', 'key_file.path', ...SitemapConfig::OPTIONS], // dotted keys only dispatchModes: ['queue', 'sync', 'none'], // [0] is what `dispatch: auto` may not be: see autoDispatch autoDispatch: static fn (): string => $queueExists ? 'queue' : 'sync', needBaseUrl: ['queue'], // a worker has no request to take the host from defaults: ['dispatch' => 'auto', 'debounce' => ['store' => 'cache']], // scalars and blocks of scalars, never lists validate: static fn (Config $c): ?string => $c->dispatch === 'queue' && !$queueExists ? 'the queue component is not configured' : null, checkCommand: 'myfw indexnow:check', ); $config = $factory->load($raw, $environment, $logger); // runtime: warning on unknown keys, critical + disabled on an error $config = $factory->build($raw, $environment); // check command, tests: throws ConfigurationException ``` The merge is deliberate: a top-level raw key replaces the default, the known blocks (`http`, `debounce`, `key_file`, `throttle`, `retry`, `batch`, `logging`) and your owned blocks merge key by key, lists (`engines`, `hosts`) come from the raw array untouched. `key_file.enabled`, `key_file.cache_max_age`, `debounce.store` and `http.client` are core options: read them from the `Config`, do not carve them out. ### Names The vocabulary is the core's, in configuration keys and method names alike, so a reader moving between adapters meets one word per concept. `locale` (the attribute's `locales`, `ResolvedUrl::$locale`, `locale_hosts`, `router.locales`, `router.locale_parameter`, `router.set_app_locale`) — not "language", even where the framework says so (Yii2 renamed its keys back in 0.12). Methods follow `submitX` / `submitXs` with the framework's word for an object: `submitEntity()` / `submitEntities()` in the core and Doctrine, `submitModel()` / `submitModels()` in Laravel, `submitRecord()` / `submitRecords()` in Yii2 and Yii3 — and the command is `submit-` (`indexnow:submit-entity`, `indexnow:submit-model`, `indexnow/submit-record`, `indexnow:submit-record`). Framework-native differences stay where the framework's own vocabulary is the point (`queue.connection` / `queue.component`, `logging.channel` / `logging.category`, `eloquent.*` / `active_record.*`). ## 5. How your framework says "this object has a public page" | Model | Core piece | |---|---| | PHP attributes on the class | `Attribute\AttributeReader` (the default) | | rules registered in code, per class or per object | `Attribute\RuleRegistry` | | your own metadata source | implement `Attribute\AttributeReaderInterface` | | the object knows its own URL | `#[IndexNowUrl]` on the method, or `Url\CallableUrlResolver` | | attributes behind `__get()` / an array (Eloquent, CMS records) | implement `Attribute\SubjectReaderInterface`, give it to the graph's `ParamExtractor` (`ServicesBuilder::paramExtractor(new ParamExtractor(new MyReader()))`, `IndexNowKit::create(extractor:)`) | `RuleRegistry` decorates any reader, so a CMS adapter keeps attribute support for free: ```php $registry = new RuleRegistry(); // wraps AttributeReader by default $registry->register(Post::class, [new IndexNow(route: 'posts.show', params: ['post' => 'self'])], new IndexNowDefaults(when: 'isPublished')); $registry->registerFor(CmsPage::class, fn (CmsPage $p): ?RuleSet => $rulesFor($p)); // null = fall through ``` Whatever the source, every path should end at `GuardedUrlResolver`, which is the only never-throwing entry point. ## 6. URLs Implement `Url\RouteUrlResolverInterface` for your router. It is deliberately two methods, so the core can re-extract parameters per locale and pin a host per rule: ```php public function locales(array|string $locales): array; // list; [null] = no locale dimension public function generate(string $route, array $params, ?string $locale = null, ?string $host = null): string; ``` - `locales('current')` returns `[null]`; `'all'` returns every locale your framework has enabled; an explicit list is returned as given. An empty list means "no locale dimension" and must become `[null]`. - `generate()` returns an **absolute** URL. Outside a request there is no host to inherit, so fall back to `$config->baseUrl`; when `$host` is given, prefer `$config->baseUrlFor($host)` and fall back to `https://$host`. - Wrap your router's exceptions in `ConfigurationException` with the route name in the message. A missing parameter is the most common attribute mistake and the message is what the user will see. - Parameters arrive already extracted and coerced. An object value means route model binding (`params: ['post' => 'self']`); decide in the bridge how your router consumes it. Without a router, use `Url\ArrayResolverLocator` to serve `#[IndexNow(resolver: ...)]`, or let models expose `url:`/`urls:` rules. Replace `Url\UrlNormalizerInterface` only to change canonical form — stripping tracking parameters, enforcing a trailing-slash policy, mapping hosts. Implementations must throw `InvalidUrlException` and nothing else, or they break the never-throw contract of `submit()`. ## 7. Hooking model changes `Url\ObjectChangeHandler` is the piece to build on. It combines rule lookup, per-rule event classification and guarded resolution, and never throws: an invalid rule set or a failing resolver is logged and yields nothing. ```php $changes = $indexNow->changes(); // or new ObjectChangeHandler($reader, $guarded, $extractor, $logger) $guarded = $indexNow->resolver(); // the GuardedUrlResolver behind it, for explain() and resolveRule() $changes->created($model); // list $changes->updated($model, $changedFields, $changeSet); $changes->deleted($model); ``` Two levels exist because ORMs differ. Hooks that run **before** the write, where ids do not exist yet, collect `RuleEvent`s first and resolve them later: ```php $events = $changes->updatedEvents($model, array_keys($changeSet), $changeSet); // list // ... the write happens ... foreach ($events as $ruleEvent) { $urls = $changes->resolve($model, $ruleEvent); } ``` Hooks that run **after** the write (observers, save hooks) call `created()` / `updated()` / `deleted()` directly. Three things decide correctness: - **Deletions must be resolved while the object still has its identifiers and old state.** That includes a rule whose `when` just turned false: `updatedEvents()` returns it as `Event::Deleted`, and it must be resolved before the write, not after. - **Supply both `$changedFields` and `$changeSet` when you have them.** The change set (`field => [old, new]`) is what makes the old-state visibility exact instead of heuristic. See [attribute-reference.md](attribute-reference.md#reconstructing-w_before). - **Never let a hook throw into the host application.** A typo in an attribute must not break a checkout. Satisfies A03, A04, A07, A08, A09, A10, A12. ### Example: an Eloquent-style observer ```php final class IndexNowObserver { public function __construct(private readonly IndexNowKit $indexNow) {} public function created(Model $model): void { $this->collect($this->indexNow->changes()->created($model)); } public function updated(Model $model): void { $changeSet = []; foreach ($model->getChanges() as $field => $new) { $changeSet[$field] = [$model->getOriginal($field), $new]; } $this->collect($this->indexNow->changes()->updated($model, array_keys($changeSet), $changeSet)); } public function deleting(Model $model): void // before the row disappears { $this->collect($this->indexNow->changes()->deleted($model)); } private function collect(array $resolved): void { $this->indexNow->collect(ResolvedUrl::urls($resolved)); } } ``` Register it so its URLs are handed over only **after** the surrounding transaction commits: resolve synchronously (the old state is live), hand off through the framework's after-commit hook (`Connection::afterCommit()` in Laravel). If your framework has no such hook, use the next section. ## 8. Commit safety URLs must not leave before the outermost transaction commits, or a rolled-back write is announced to search engines. `Transaction\TransactionStaging` lives in the core precisely so every adapter solves this the same way. ```php $staging = new TransactionStaging(sink: fn (array $urls) => $indexNow->collect($urls), logger: $logger); $staging->stage($scope, $urls); // inside an open transaction $staging->commit($scope); // real COMMIT: hands the URLs to the sink $staging->discard($scope); // ROLLBACK, or a commit that threw: drops them, logged at debug ``` `$scope` is any object whose identity outlives the transaction — the native database connection is the usual choice. Entries are held in a `WeakMap`, so a forgotten scope does not leak. `hasPending()` and `pendingCount()` are there for diagnostics. Where the real commit signal comes from differs per framework: a DBAL driver middleware (Doctrine), `Connection::afterCommit()` (Laravel — its transaction manager already drops callbacks of a rolled-back savepoint, so the Laravel adapter needs no staging of its own; `ShouldHandleEventsAfterCommit` is *not* used, because a deferred `updated` handler runs after `syncOriginal()` and loses the old values), `transaction.on_commit` (Django). When the framework offers none, use `Transaction\VerifyingStaging` instead of guessing. Satisfies A01, A02, A05. ### 8a. No commit signal at all: verify on commit Yii2 fires commit/rollback events only for the outermost transaction and nothing for savepoints; Yii3's `yiisoft/db` fires nothing. `Transaction\VerifyingStaging` holds URLs together with a *verifier*, a closure that re-reads the row by primary key and says whether the change actually landed (created/updated: the row exists with the new values, `VerifyingStaging::rowMatches($row, $expected)`; deleted: no row): ```php $staging = new VerifyingStaging($logger); // in the ORM event, when a transaction is open. The last argument is the subject key: the identity of the row // within this transaction, so a second change of the same row merges into the first entry instead of adding one. $subject = Post::class . '#' . $id; $staging->stage($connection, fn (): bool => $this->rowMatches($record, $written), $urls, $subject, key: $subject); // when the data layer says the transaction ended (commit event), or at the end of the request when it says nothing: $indexNow->collect($staging->flush($connection)); // runs the verifiers, drops what did not land (logged at debug) $staging->discard($connection); // on a rollback event: nothing to verify ``` One primary-key lookup per staged subject, only for changes inside an explicit transaction (autocommitted changes go straight to the collector). A change that did not land drops every URL it produced, including `via` pages and the old URL of a renamed page: announcing "deleted" for a page that still exists is the one outcome to avoid. A verifier that throws counts as landed (a stale URL costs one crawl, a lost one costs the update) and is logged at warning. Two rules decide what to put in `$expected`, and getting them wrong is silent — every URL of the change disappears at `debug` level: - **An insert is verified by existence, nothing else.** Pass an empty `$expected`: an insert that reached the commit landed by definition, and the row being there is the whole answer. Handing `rowMatches()` every non-null property of a new record is how a single DECIMAL or `timestamptz` column silences the announcement of every new page of a class. A delete asks for `null` the same way. - **For an update, only unambiguous values are compared.** The row comes back raw from the driver — a string for an integer, `'19.90'` for what the application wrote as `19.9`, a zone suffix on a `timestamptz`, the database's own spelling of a JSON document. `rowMatches()` therefore compares integers, strings, booleans, backed enums and null, and skips everything else (floats, dates, arrays, objects) as if the row did not carry the column. A row with nothing left to compare counts as matching. Do not filter `$expected` yourself and do not add a comparison of your own on the skipped types: the question the verifier answers is "did the transaction commit", not "is the row byte for byte what I wrote". Pass the subject key whenever a row can be written more than once inside one transaction — two `save()` calls, a create followed by an update, a rename in two steps. Without it each write is staged on its own, every verifier runs at the end against the **last** state of the row, and the earlier ones compare against values that are no longer there. With it the entry is merged: the URLs of both writes are kept and the verifier of the later write is the one that runs. Satisfies A02, A05, A05b, A05c without touching the connection configuration; the Yii adapters are the reference (Yii2 flushes on the commit event of the outermost transaction, Yii3 at the end of the request, keeping what is still inside an open transaction). ## 9. The unit of work `Collector\CollectorInterface` buffers normalized URLs for one HTTP request, console command or queue message, and `IndexNowKit::flush()` drains it into the dispatcher exactly once. Call `flush()`: - after the response has been sent, where the platform allows it (`kernel.terminate`, `fastcgi_finish_request`); - at the end of a console command; - after each handled queue message. In long-running runtimes call `CollectorInterface::reset()` between requests. The default `Collector` logs a `warning` when a reset discards a non-empty buffer, which is the signal that a unit of work ended without a flush. Do not swallow it. Replace the interface for a durable outbox or a per-tenant buffer. Satisfies A06, H06. ## 10. Delivery `Dispatch\DispatcherInterface` has one method and must never throw into user code. `SyncDispatcher` sends inline, `CallableDispatcher` hands the list to any queue, `NullDispatcher` drops it (`dispatch: none` — collect, never send). The worker recipe is the same everywhere: `submit()`, then `Result::retryableUrls($results)`, then `RetryPolicy::delayAfter($results, $attempt)`, then re-enqueue. Which statuses are final and which are retryable is in [retries-and-queues.md](retries-and-queues.md). Satisfies A14, C13. A worker has no request context, so `base_url` must be set or every relative URL is dropped. ## 11. Keys and the key file `Key\KeyProviderInterface` has four methods and is called on the submission path, so implementations must be cheap and must never throw for an unknown host: ```php public function keyFor(string $host): ?string; // null = unmanaged, URLs are skipped public function keyLocationFor(string $host): ?string; public function isKnownKey(string $key, ?string $host = null): bool; // serve /{key}.txt? public function managedHosts(): array; // diagnostics; empty when unknown ``` `StaticKeyProvider::fromConfig($config)` covers config-backed setups including `strict_hosts`. For a database-backed multi-tenant install, implement the interface and cache per request. **Honour the `$host` argument of `isKnownKey()`**: without it, tenant A's key file is served on tenant B's host, which lets one tenant claim ownership signals on another's domain. `null` means "any managed host" and is only for single-site adapters and CLI diagnostics. Serving the file on a PSR-7 / PSR-15 stack (Slim, Mezzio, Laminas, Yii3, a plain PSR-15 pipeline) is `Key\KeyFileRequestHandler`, the first recipe for a new adapter — no class of your own: ```php $handler = KeyFileRequestHandler::fromConfig($config, $keys, $responseFactory, $streamFactory); // PSR-17 factories of the application $app->get('/{key:[A-Za-z0-9-]{8,128}}.txt', $handler); // a route: handle() reads the key off the path, 404 otherwise $app->add($handler); // or a middleware before the router: a request it does not serve goes on return $handler->respond($route->getArgument('key'), $request); // or the key your router already extracted ``` It answers 200 with the key and `Config::keyFileHeaders()` for a key of the requested host (the URI host), 404 otherwise; as a middleware it hands everything else — a path that is not a key file, an unknown key, another host's key — to the next handler untouched. The core requires only the PSR interfaces (`psr/http-server-handler`, `psr/http-server-middleware`); tests use `nyholm/psr7`. Behind it, and for a stack that is not PSR-7 (HttpFoundation, Illuminate), `Key\KeyFileResponder` decides so no adapter reimplements the matching: ```php $responder = new KeyFileResponder($keys, $config->serveKeyFile); $body = $responder->bodyForPath($request->getPath(), $request->getHost()); // or bodyForKey() if your router if ($body === null) { return $this->notFound(); } // extracted {key} already return $this->response($body, 200, $config->keyFileHeaders()); ``` `KeyFileResponder::PATH_PATTERN` is the request-path regex (group 1 is the key) for routers that match by pattern, `CONTENT_TYPE` is `text/plain; charset=utf-8`, and `DEFAULT_MAX_AGE` is 300 seconds — short on purpose, because a cached old key file turns every submission into a 403 after a rotation. Serve 200 with no redirect, 404 otherwise. Which of the two a stack gets, in one table — the recipe is decided by whether the framework's request is PSR-7, not by taste: a bridge (`symfony/psr-http-message-bridge` plus a PSR-17 implementation) costs two dependencies in `require` for thirty lines, so the HttpFoundation and Illuminate adapters keep a controller of their own. | Stack | Recipe | |---|---| | Yii3, Slim, Mezzio, Laminas, any PSR-15 pipeline | `Key\KeyFileRequestHandler` as a route handler or a middleware (above); the Yii3 package delegates to it | | Symfony (HttpFoundation) | a controller over `Key\KeyFileResponder` (`SymfonyBundle\Controller\KeyFileController`) | | Laravel (Illuminate, HttpFoundation underneath) | a controller over `Key\KeyFileResponder` (`Laravel\Http\KeyFileController`) | | Yii2 | a controller over `Key\KeyFileResponder` and `Config::keyFileHeaders()` | | Bitrix, a plain `index.php` | `KeyFileResponder::bodyForPath($_SERVER['REQUEST_URI'], $_SERVER['HTTP_HOST'])` and `header()` per `Config::keyFileHeaders()` | `Key\KeyGenerator::generate($length, $hex)` produces CSPRNG keys, 32 hex characters by default; pass `hex: false` for the full `[A-Za-z0-9]` alphabet. A `key:generate --write-env` style command is the first thing users run. Satisfies H01–H03. ## 12. Transport `Http\TransportInterface` is two methods over any HTTP stack — `wp_remote_post()`, a framework client, raw curl: ```php public function post(string $url, string $json, array $headers = []): Response; public function get(string $url): Response; ``` Rules: never throw on an HTTP status code, throw `Http\Exception\TransportException` for network failures and timeouts, cap the body you read (`Psr18Transport` uses 2 KiB for POST diagnostics and 50 MiB for GET, a generous cap for the largest documents consumers of the transport read). Parse `Retry-After` with `Response::parseRetryAfter($header)` so every adapter interprets delta-seconds and HTTP-dates identically and applies the same clamp. Configure no redirects and a timeout. Redirects deserve a sentence, because PSR-18 says nothing about them: the standard leaves following a 3xx to the client, and clients differ (Guzzle follows five by default, Symfony's twenty, a hand-built curl none). `Psr18Transport` switches redirects off on the clients it builds itself (`max_redirects: 0`, `allow_redirects: false`); a client the application hands in through `http.client` keeps its own defaults, and `check` warns about it (`http.client`), because a key file that redirects to a catch-all page then looks like a 200 — the conformance scenario H02 ("the key file answers without a redirect") cannot be verified honestly on such a client. When an application must pass its own client, pass one configured without redirects. On a PSR-7 stack, hand the transport the application's PSR-17 factories as well (`Psr18Transport::discover(requestFactory:, streamFactory:)`, `TransportFactory::lazy(…, requestFactory:, streamFactory:)`), so that a request is built by the same implementation the rest of the application uses and `php-http/discovery` is never consulted. Implement `Http\StreamingTransportInterface` too when your stack can read a response body in chunks (`download(string $url, $sink): Response` writes the body to a stream resource and returns an empty-bodied `Response`). Consumers that read large documents (the add-on packages) then never hold a document in memory; with a plain `TransportInterface` they buffer each document once through `get()`. `LazyTransport` and `Testing\FakeTransport` implement both. ## 13. Debounce, throttle, clock `MemoryDebounceStore` is per process and bounded; `Psr16DebounceStore` shares the window across processes through any PSR-16 cache; `NullDebounceStore` disables it. Wire your framework's cache to the PSR-16 one by default for web applications, and memory for CLI and tests. A debounce store may throw: the submitter treats a failing read as "nothing is recent" and a failing write as "window not recorded", logs a warning, and delivers anyway. Preserve that fail-open behaviour in your own store. The core takes PSR-16 and only PSR-16. A stack whose cache is a PSR-6 pool (Symfony's `cache.app`, a Laminas `StorageInterface`) wraps it in the PSR-16 view its own ecosystem ships — `Symfony\Component\Cache\Psr16Cache`, `Laminas\Cache\Psr\SimpleCache\SimpleCacheDecorator` — and hands that to `Psr16DebounceStore`; the bundle does exactly this. A `Psr6DebounceStore` of the core would be a second store to keep in step for nothing the view does not give. Two questions every adapter answers the same way: `Debounce\DebounceStoreFactory::isShared($store)` says whether `debounce.store` names a cache shared by every process (the 403 counter, the robots cache of verify and the `psr16` history store then share it; `memory` and `none` keep them in the process), and the probe of `Check\DebounceStoreCheck` writes `DebounceStoreCheck::PROBE_KEY` — a key without the characters PSR-16 reserves, so a strict cache does not refuse it and report a working store as broken. `TokenBucket` blocks with `usleep()` per process. In a web request `NullThrottle` is often the better default, with the real rate limiting in the queue worker. Both take a `Psr\Clock\ClockInterface`, so tests use `FrozenClock`. **Open the clock as a replaceable service or binding of its own**, under `Psr\Clock\ClockInterface`, and pass it to all three places that read time: `Throttle\TokenBucket::fromConfig($config, $logger, clock: …)`, `Debounce\DebounceStoreFactory::fromConfig(…, clock: …)` and the submitter (`Submitter`'s last parameter, and `Adapter\SubmitterFactory`'s, which is what the commands build their submitter with). `Testing\FrozenClock` is useless otherwise: an application that binds it still gets wall-clock debounce windows, wall-clock throttle waits and wall-clock timestamps in the submission history — and the records written by a command end up on a different clock from the records written by the application. On layer 2 this is the `clock` node (`Adapter\ServicesBuilder::clock()`, `Adapter\Services::clock()`), which every other node already reads. ## 14. Diagnostics users will ask for Ship six commands of your own. They are what turns "it does not work" into a self-service answer, and their bodies are the `indexnowkit/console` package (`Console\*Runner`, rendering to a `Symfony\Component\Console\Style\SymfonyStyle`; Laravel's `OutputStyle` is one; require it — the core itself does not depend on `symfony/console`). A framework command parses its arguments and calls the runner; every framework prints the same thing. **The command lives in the package, the adapter registers it.** On `symfony/console` the commands are classes already: `Console\Command\*` of `indexnowkit/console` (`SubmitCommand`, `SubmitSubjectsCommand`, `ExplainCommand`, `CheckCommand`, `ConfigCommand`, `KeyGenerateCommand`, the three `*NotInstalledCommand` stubs), `Sitemap\Console\SitemapCommand`, `History\Console\HistoryCommand` and `StatusCommand`. An adapter on symfony/console (the bundle, Yii3, a plain `Symfony\Component\Console\Application`) registers those classes and hands them what varies by constructor — the runners, a `Console\Vocabulary`, a `Console\ConfigSourceInterface` for `check` and `config`, the `.env` file of `key:generate`, a `Check\SampleOptions` with the ORM sampler already inside — and writes no command of its own. The table below is what a command **not** on symfony/console (artisan, a Yii2 controller) parses and hands to the runner. | Command | Runner | What the adapter supplies | |---|---|---| | `check` | `Console\CheckRunner` | a closure that builds `Config` from the raw configuration (throws `ConfigurationException`); `Check\CheckInterface` services for adapter wiring (is the ORM hook active? is the queue routed?) and the checks of the add-on packages. `--sample` / `--sample-class` go into the graph's `Check\SampleOptions`, which `Check\SampleGateCheck` reads (§2 "Optional packages"); the ORM sampler behind it is yours | | `config` | `Console\ConfigRunner` | the closure that builds the `Config`, the raw configuration array as the framework read it (its own blocks included, for the adapter-only keys) and the effective block of every installed optional package. The runner prints the effective values with the keys masked, the adapter-only keys next to them, and masks secrets in both. `config --json` is what a bug report pastes | | `submit ...` | `Console\SubmitRunner` | — | | `submit- [ids]` | `Console\SubmitSubjectsRunner` + `SubmitSubjectsOptions` | a `Console\SubjectLoaderInterface`: class resolution (FQCN or the framework's short name), objects by id, first N objects; `byIds()` / `all()` receive the `Event` so `deleted` can include soft-deleted rows | | `explain ` | `Console\ExplainRunner` | the same loader | | `key:generate` | `Console\KeyGenerateRunner` | the default env file path | Three more commands come from the optional packages, so a complete adapter registers nine: `sitemap` submits the site's own URL list (`indexnowkit/sitemap`), and `history` and `status` read what was submitted (`indexnowkit/history`: `History\Console\Definitions::history()` / `::status()` for the inputs, `History\Adapter\HistoryServices::historyRunnerFor()` / `::statusRunnerFor()` for the bodies; `status` also takes the queue facts only the adapter knows). Each of the three is behind the package predicate of §2, with a stub command of the same name when the package is absent. `Console\Definitions` and the packages' own `Definitions` are the single source of every command name, option, shortcut and description — an adapter that writes its own option descriptions has already drifted. Shared by all of them: `Adapter\SubmitterFactoryInterface` (`SubmitterFactory`: the separate submitter `--force` and `--dry-run` build, `SubmitterFactory::choose()` picks it or the application's), `Console\ResultFormatterInterface` (`ResultRenderer`: table or `--json`; an application replaces it to match its own CLI), `Submission\ResultSummary` (a run that submits in many batches folds results into counts) and `Console\Vocabulary` (the words that differ: "entity" / "model", `bin/console` / `php artisan`, where the configuration lives). Expose the three interfaces under stable service ids so an application can decorate them, and expose the runners too: a tenant loop over `SubmitSubjectsRunner` is a ten-line application command. `Checker` never throws and covers configuration, key files and a live probe; `CheckRunner` prints its report. The adapter-specific lines are `CheckInterface` services tagged for the checker, not special cases in the command. Result listeners (`SubmitterInterface::addListener()`) feed an admin log or a profiler panel. Register the listener on the same submitter instance the application uses, and forward `addListener()` in any decorator. ## 15. The error contract | Situation | Behaviour | |---|---| | invalid `Config` | throws `ConfigurationException` at construction | | invalid rule declaration read through `AttributeReaderInterface` | throws `ConfigurationException` | | invalid rule declaration read through `ObjectChangeHandler` / `GuardedUrlResolver` | logged at `error`, yields no URLs | | resolver failure (missing accessor, router error) in a hook | logged at `error`, yields no URLs | | URL that cannot be submitted | `InvalidUrlException` inside the normalizer, caught by `Submitter`, `warning` + `skipped` result | | HTTP status of any kind | never throws; a `Result` with a `Reason` | | network failure | `TransportException` inside the transport, converted to a retryable `failed` result | | debounce store, throttle, listener or dispatcher failure | logged, delivery continues | | programming errors (empty batch, bad key length) | `InvalidArgumentException` | The golden rule: **nothing reaching a lifecycle hook may throw into the host application.** ### What your adapter throws The core's own promise is that every exception implements `Exception\IndexNowException`, so `catch (IndexNowException $e)` is the stable form ([bc.md](bc.md#exceptions)). An adapter that throws its framework's exception class for a situation the core has a class for breaks that catch for its users, and the family has drifted here before: the same "`history.store` is set but no store could be built" was `yii\base\InvalidConfigException` in one adapter and `Exception\ConfigurationException` in another. One rule for all of them: | Situation | Throw | |---|---| | anything wrong with the configuration the application wrote (an unknown service id, a `debounce.store` that is not a cache, a store that cannot be built) | `Exception\ConfigurationException` — never the framework's own configuration exception, even where the framework has one | | a delegate of an optional package that is not installed (a stub command's collaborator, an accessor behind the predicate of §2) | `\LogicException` with `Adapter\OptionalPackage::notInstalledMessage()` as the message, so the sentence names the `composer require` line once | | an invariant of your own code that the application cannot cause (a node that must exist by construction, an unreachable branch) | the native class (`\LogicException`, `\RuntimeException`); it is a bug report, not a user error | `Adapter\Services::requireRouter()` and `requireResolverLocator()` exist so the second row does not become a third copy of `?? throw` in every adapter: they throw a `ConfigurationException` naming the missing node. And nothing in this table applies inside a lifecycle hook — there, the golden rule wins and everything is logged instead. ## 16. Testing your adapter Use `IndexNowKit\Testing` (`FakeTransport`, `ArrayLogger`, `FrozenClock`, `RecordingDispatcher`) — see [testing.md](testing.md). Assert classification through `ObjectChangeHandler::*Events()` before any URL exists, and delivery through `RecordingDispatcher`. For the HTTP and command scenarios, parse your framework's response or output and hand it to `Testing\Conformance\KeyFileAssertions` (H01–H03: status, content type, `Cache-Control` by directive, `Vary: Host` exactly when `Config::keyFileHeaders()` adds it — a hosts map or `strict_hosts`) and `Testing\Conformance\CheckOutputAssertions` (H04–H05: exit code with the output as the failure message, the ready line, the key file hint), so your tests do not carry a copy of the core's phrases. Both, the conformance kits and the mock server are the `indexnowkit/testing` package (`require-dev`); the core ships only the four PHPUnit-free doubles. Then work through the conformance scenarios with the kits of `indexnowkit/testing` (`Testing\Conformance\CoreConformanceTestCase`, `OrmConformanceTestCase`, see [testing.md](testing.md)): C01–C22 for anything that talks to the protocol, A01–A21 for an ORM adapter, H01–H06 for a framework adapter. Declare in your README which scenarios do not apply to your framework and why — A13 (bulk operations bypass hooks) is a documented limitation everywhere, not a failure. ## 17. Packaging Name it `indexnowkit/`, require `indexnowkit/core ^0.5`, keep the framework itself in `require` and the optional pieces in `suggest` (`indexnowkit/sitemap ^0.1.1` for the `sitemap` command and its `Definitions`, wired as in §2 "Optional packages"; keep it in `require-dev` so the tests cover both states). Run a version matrix in CI over the framework's supported majors and LTS releases, static analysis at the maximum level, and publish EN plus RU READMEs following the family table used here. The Definition of Done is in [docs/spec/91-roadmap.md](https://github.com/indexnowkit/spec/blob/main/91-roadmap.md). ## 18. Reference adapters The bundle and the Laravel package sit on layer 1 (the static factories and `Adapter\ConfigFactory`, one service or binding per node, because those ids are their public API); the Yii2 component sits on layer 2 (`Adapter\ServicesBuilder`, the graph described once, the pieces exposed as delegates); the Yii3 package sits on layer 2 too, with every node of the graph a definition of the container and the core's factory as the default of each. All of them share `Hook\ObserverHelper` in the observers, `Retry\WorkerOutcome` in the queue jobs and `Console\Definitions` (`indexnowkit/console`) in the commands. | Section | `doctrine` | `symfony-bundle` | `laravel` | `yii2` | `yii3` | |---|---|---|---|---|---| | layer | — | 1 (services) | 1 (bindings) | 2 (`ServicesBuilder`) | 2 (`ServicesBuilder`, every node a container definition) | | component graph | `src/IndexNowDoctrine.php` | `src/DependencyInjection/IndexNowKitLoader.php` | `src/IndexNowKitServiceProvider.php` | `src/IndexNowComponent.php` (`services()`) | `src/Wiring.php`, `config/di.php` | | configuration | — | `src/DependencyInjection/{IndexNowKitConfiguration,ConfigFactory}.php` | `config/indexnow.php`, `src/Config/ConfigFactory.php` | `src/Config/ConfigFactory.php` | `config/params.php`, `src/Config/ConfigFactory.php` | | router bridge | — | `src/Url/SymfonyRouteUrlResolver.php` | `src/Url/LaravelRouteUrlResolver.php` | `src/Url/YiiRouteUrlResolver.php` | `src/Url/YiiRouteUrlResolver.php` (`UrlGeneratorInterface`) | | resolver lookup | — | `src/Url/ResolverLocatorFactory.php` (core `ArrayResolverLocator`) | in the provider (core `ArrayResolverLocator`) | in the component (core `ArrayResolverLocator`) | in `Wiring` (core `ArrayResolverLocator` over the container) | | model change hooks | `src/IndexNowListener.php` | via the Doctrine package | `src/Eloquent/IndexNowObserver.php` (`ObserverHelper` + `afterCommit()`) | `src/ActiveRecord/IndexNowObserver.php` (`ObserverHelper` + staging), `IndexNowBehavior.php` | `src/ActiveRecord/IndexNowObserver.php` (`ObserverHelper` + staging), `IndexNowEvents.php` (attribute handlers) | | commit safety | `src/Middleware/*` | `src/Doctrine/StagingSink.php` | Laravel's `afterCommit()` | core `VerifyingStaging` | core `VerifyingStaging`, verified at the end of the request | | unit of work | — | `src/EventListener/FlushListener.php` | `terminating()`, `JobProcessed` | `EVENT_AFTER_SEND`, `EVENT_AFTER_REQUEST` | `src/Event/FlushListener.php` (`AfterEmit`, `ApplicationShutdown`) | | delivery | — | `src/Messenger/*` (`WorkerOutcome`) | `src/Queue/*` (`WorkerOutcome`) | `src/Queue/*` (yii2-queue, `WorkerOutcome`) | `sync` / `none`; a replaced `DispatcherInterface` | | key file | — | `src/Controller/KeyFileController.php`, `config/routes.php` | `src/Http/KeyFileController.php` | `src/Http/KeyFileController.php` | `src/Http/KeyFileHandler.php` over the core's `Key\KeyFileRequestHandler` (PSR-15), `config/routes.php` | | diagnostics | — | the command classes of `indexnowkit/console`, `sitemap` and `history`, registered in `src/DependencyInjection/IndexNowKitLoader.php` (`loadConsole()`); `src/DataCollector/*` | `src/Console/*` (`Definitions`), `src/Check/*` | `src/Console/IndexNowController.php` (`Definitions`), `src/Check/*` | the same command classes, mapped in `config/params-console.php`, wired in `config/di-console.php`; `src/Check/*` | | subject reader | — | — | `src/Eloquent/EloquentSubjectReader.php` | `src/ActiveRecord/ActiveRecordSubjectReader.php` | `src/ActiveRecord/ActiveRecordSubjectReader.php` | ## 19. Compatibility What the core guarantees, what is excluded, and how to ask for a new extension point instead of reaching into `@internal`: [bc.md](bc.md). ## 20. Definition of Done for an adapter - [ ] `Adapter\ConfigFactory` declared with dotted `ownedOptions` (plus `SitemapConfig::OPTIONS` when the package is installed, `ignoreBlocks: ['sitemap']` when it is not); a regression test that a typo inside an owned block (`key_file.enabld`) is warned about; an invalid runtime value disables IndexNow with one `critical` line and never throws from a hook. - [ ] The graph is built through the factories (`Http\TransportFactory`, `Debounce\DebounceStoreFactory`, `Dispatch\DispatcherFactory`, `fromConfig()`); no copied `match` over `debounce.store`, no own "not a PSR-18 client" text, no own class-name resolution (`Console\ClassNameResolver`). - [ ] `#[IndexNow(resolver: ...)]` through `ArrayResolverLocator(locate:, hint:)`; the resolver is `GuardedUrlResolver`. - [ ] Hooks over `Hook\ObserverHelper` (guard, deliver, remembered deletions; no own `WeakMap`, no own "cannot resolve" text); deletions resolved before the row disappears; a commit boundary (`afterCommit`, `TransactionStaging`, `VerifyingStaging`). - [ ] A queue job over `Retry\WorkerOutcome` (retryable vs final, the three log lines) plus your framework's action; or a runtime-assembled container over `Adapter\ServicesBuilder` with `queueFactory()`. - [ ] Flush at the end of every unit of work (request, command, queue message); `Collector::reset()` in long-running runtimes. - [ ] `Key\KeyFileRequestHandler` on a PSR-15 stack, else `KeyFileResponder::fromConfig()` + `Config::keyFileHeaders()`, on a route without session or CSRF; H01–H03 green. - [ ] Nine commands: on symfony/console the classes of `indexnowkit/console` (`Console\Command\*`), `indexnowkit/sitemap` (`Sitemap\Console\SitemapCommand`) and `indexnowkit/history` (`History\Console\HistoryCommand`, `StatusCommand`), registered with their runners, a `Vocabulary`, a `ConfigSourceInterface` and a `SampleOptions`; on another console layer six commands over the runners of `indexnowkit/console` (`check`, `config`, `submit`, `submit-`, `explain`, `key:generate`), `sitemap` from `indexnowkit/sitemap`, `history` and `status` from `indexnowkit/history`; their inputs from `Console\Definitions` / `Sitemap\Console\Definitions` / `History\Console\Definitions` (no own option descriptions), `check` with your `CheckInterface` lines plus `Check\DebounceStoreCheck` (with a probe), `Check\SampleGateCheck` and `Sitemap\Check\SitemapSpoolCheck`. - [ ] `indexnowkit/sitemap` in `suggest` and `require-dev`, behind one predicate (§2 "Optional packages"): without it the `sitemap` command is a stub that explains what to install and exits 1, `check` prints the `StaticCheck` line, a `sitemap` block in the configuration warns about nothing, every other command works, and nothing is logged at boot; a test set with the predicate forced to false. - [ ] `indexnowkit/testing` in `require-dev`; conformance kits green (C01–C22, A01–A21 for an ORM, H01–H06 through `Testing\Conformance\KeyFileAssertions` and `CheckOutputAssertions`); undocumented scenarios named in the README. - [ ] CI matrix over the framework's supported majors, phpstan level 9 on every flavour, EN + RU README with the family table, `docs/troubleshooting.md`, a changelog with migration notes. # Backward compatibility `indexnowkit/core` follows SemVer. **Before 1.0, minor versions may contain breaking changes**; every one is listed under "Changed" in [CHANGELOG.md](https://github.com/indexnowkit/php/blob/main/packages/core/CHANGELOG.md) with the migration. After 1.0 the rules below become the promise. This page exists because "public API" is ambiguous for a library whose main audience is other library authors. Which PHP and framework versions a release supports, and the rule for dropping one, is [compatibility.md](compatibility.md); **raising the minimum PHP version is not a breaking change** under this promise (Composer does not offer the new minor to an application on the old PHP). ## Three tiers | Tier | What it means | Examples | |---|---|---| | **Call** | You call it. Signatures do not change incompatibly; new parameters are only appended with defaults. | `IndexNowKit`, `Config` (including the static `serveKeyFileFrom()`, `fromEnv()` and `arrayFromEnv()`), `Submitter`, `Client`, `Result`, `Checker`, `KeyGenerator`, `KeyFileResponder`, `Key\KeyFileRequestHandler` (the PSR-15 handler and middleware over it; the constructor and `fromConfig()` take named arguments), `RetryPolicy`, `ObjectChangeHandler`, `GuardedUrlResolver`, `RuleRegistry`, `Transaction\VerifyingStaging`, `Adapter\SubmitterFactory`, `Submission\ResultSummary`, `Adapter\ConfigFactory`, `Adapter\ServicesBuilder`, `Adapter\Services`, `Adapter\OptionalPackage` (including the static `sitemap()`, `verify()`, `history()`), the factories (`Http\TransportFactory`, `Debounce\DebounceStoreFactory`, `Dispatch\DispatcherFactory`, every `fromConfig()`), `Check\DebounceStoreCheck`, `Check\StaticCheck`, `Check\LocalesCheck`, `Check\DispatchLine`, the writers of `Check\CheckReport`, `Dispatch\BatchingDispatcher` (including the static `newJobId()`), `Url\RouteOrigin` (static helpers of the router bridges), `Hook\ObserverHelper`, `Retry\WorkerOutcome`, `Retry\ForbiddenCounter`, `Submission\NullSubmissionStore`, the four test doubles of `Testing\` | | **Implement** | You implement it, and the core calls you. Methods are not added without a major version. | `TransportInterface`, `StreamingTransportInterface`, `Url\RuleAwareUrlResolverInterface` (until 1.0 a method may still be appended in a minor), `Url\ParamExtractorAwareInterface`, `Url\RouteUrlResolverInterface` (one implementation per framework adapter) and `Url\ResolverLocatorInterface` (one shipped implementation, `Url\ArrayResolverLocator`, which every adapter configures with closures rather than replacing); a capability the core needs later comes as a new interface that extends them, the way `History\HistoryStoreInterface` extends `SubmissionStoreInterface`, `Check\CheckInterface`, `KeyProviderInterface`, `UrlNormalizerInterface`, `UrlResolverInterface`, `DebounceStoreInterface`, `ThrottleInterface`, `DispatcherInterface`, `Attribute\SubjectReaderInterface`, `Adapter\SubmitterFactoryInterface`, `Submission\SubmissionStoreInterface` (new in 0.8, see [submission-store.md](submission-store.md)), `Attribute\Param\Condition` and `FieldCondition` (new in 0.8, the `when` guards); the three new interfaces live through one minor unchanged before 1.0 | | **May grow** | Interfaces the core also implements for you, where a new method may appear in a minor. Extend the shipped class rather than implementing the interface from scratch. | `ClientInterface`, `Check\CheckerInterface`, `SubmitterInterface`, `CollectorInterface`, `AttributeReaderInterface` | | **Sealed** | Closed sets the core switches over. Do not implement them: an unknown implementation is a configuration error, or worse, a silent miss. | `Attribute\Param\ParamValue` (`Accessor`, `Value`, `Formatted`, `Call` are the set: a param source of your own is a resolver, `#[IndexNow(resolver: …)]`) | The "may grow" tier is the honest label for interfaces that are still learning what adapters need. If you implement one directly, pin `^0.8.0` rather than `^0.8` and read the changelog before upgrading. Decorating a shipped implementation (`RetryingSubmitter` decorates `Submitter`, `RuleRegistry` decorates `AttributeReader`) is safe in both directions. `RouteUrlResolverInterface` and `ResolverLocatorInterface` used to be listed here; they are "Implement" since 0.11, for two different reasons. `RouteUrlResolverInterface` has no shipped implementation to decorate — there is one per framework adapter — so a method cannot be added to it in a minor. `ResolverLocatorInterface` does have one, `ArrayResolverLocator`, but no adapter decorates or replaces it: all four configure the shipped class with the `locate:` and `hint:` closures, so nothing would gain from the interface growing, and the stricter tier is what the four call sites actually rely on. ## Named arguments `IndexNowKit::create()` takes only named optional parameters after `$config`, and will take more; the list is the signature, and that is the one place to read it. **Parameter names are part of the promise; the order is not.** New parameters are appended, never inserted, and every call should use named arguments: ```php IndexNowKit::create($config, transport: $transport, logger: $logger, resolver: $resolver); ``` The same holds for the constructors of `Config`, `Client`, `Submitter`, `AttributeUrlResolver`, `GuardedUrlResolver`, `TransactionStaging`, `VerifyingStaging`, `RetryPolicy`, `TokenBucket`, `Collector` and `Psr18Transport`: pass anything past the first argument by name. `RuleCompiler` (`compile()`, `fromAttributes()`) is a public static helper in the same "call" tier: adapters call it to compile their own declarations; its signatures only grow by appended optional parameters. `Attribute\ParamExtractor` is an object of the same tier: `new ParamExtractor(...$readers)` takes the `SubjectReaderInterface`s of the graph, `extract()`, `read()`, `resolve()`, `condition()` read with them, `with()`/`fromReaders()`/`readers()` compose. One instance per graph — `IndexNowKit::create(extractor:)`, `Adapter\ServicesBuilder::paramExtractor()`, the adapters' `ParamExtractor` binding or service — shared by `AttributeUrlResolver`, `ObjectChangeHandler` and the `explain` command; the constructors and `fromConfig()` of those take it as a required parameter (since 0.12: a graph never falls back to the DSL by omission; `ParamExtractor::plain()` says so when the DSL alone is meant). `IndexNowKit` derives its extractor from the resolver it is given (`Url\ParamExtractorAwareInterface`, which `AttributeUrlResolver` implements), so the change handler and `explain` read exactly what the resolver reads; a custom resolver without the interface falls back to the plain DSL. `ObjectChangeHandler::renamed(object $subject, array $changeSet, ?object $previous = null, array $selfFields = [])` is the contract for the "old URLs of a renamed object": the core rebuilds the previous state by reflection from the change set, and an adapter whose objects cannot be reset that way (Eloquent attributes) passes `$previous`, a copy of the object as it was. That stays the design: `Attribute\SubjectReaderInterface` is read-only (`supports()`, `has()`, `read()`) and gets no `write()` — writing into an ORM object (dirty tracking, model events, casts) is the adapter's business, and the adapter knows how to produce a before-image (`replicate()->setRawAttributes(getOriginal())` in Eloquent) better than the core. The shipped default implementations are in the "call" tier as well: construct them with named arguments and their public methods stay. That is `Http\LazyTransport` (the default `IndexNowKit::$transport`), `Http\Psr18Transport`, `Key\StaticKeyProvider`, `Url\UrlNormalizer`, `Url\ArrayResolverLocator`, `Url\CallableUrlResolver`, `Url\NullUrlResolver`, `Attribute\AttributeReader`, `Attribute\ChangeClassifier`, `Collector\Collector`, `Debounce\{MemoryDebounceStore, Psr16DebounceStore, NullDebounceStore}`, `Throttle\NullThrottle`, `Dispatch\{SyncDispatcher, CallableDispatcher, NullDispatcher}` and `Clock\SystemClock`. `Config::with()` takes constructor parameter names as keys and rejects unknown ones with a message listing what it accepts. Renaming a `Config` property is therefore a breaking change and appears in the changelog. ## Value objects and enums `Result`, `ResolvedUrl`, `UrlRule`, `RuleSet`, `RuleEvent`, `Http\Response`, `Check\CheckItem`, `Retry\WorkerOutcome`, `Submission\SubmissionRecord` and the attribute classes are `final readonly`. Their properties are read-only public API: reading them is safe, constructing them is safe, and new properties are only appended with defaults. Prefer the named constructors (`Result::ok()`, `Result::skipped()`, `Result::failed()`) over the constructor, so an appended parameter never reaches your call sites. Enums are a special case: **adding a case is not a breaking change** in this library, because the wire protocol and the failure taxonomy grow. | Enum | Adding cases? | |---|---| | `Reason` | yes — always handle unknown cases with a `default` arm | | `Engine` | yes — new IndexNow participants get added | | `Attribute\RuleSource` | yes | | `Event`, `ResultStatus`, `Check\CheckLevel` | no; these are closed sets | A `match` over `Reason` or `Engine` without a `default` will fatal on a new case. Write the default arm. The `Reason` cases and what they mean for a `Result` (`isSkip()`: nothing was sent; `isRetryable()`: a later attempt may succeed by itself): | Case | `isSkip()` | `isRetryable()` | Produced by | |---|---|---|---| | `disabled`, `dry_run`, `debounced`, `no_key`, `invalid_url` | yes | no | the core pipeline | | `noindex`, `robots_disallowed`, `non_canonical`, `redirected` | yes | no | the `verify` package's pre-flight (cases reserved in core 0.8) | | `origin_error` | yes | yes | `verify`: the page could not be fetched | | `invalid_request` (400), `invalid_key` (403), `unprocessable` (422), `unexpected` | no | no | the engine's answer | | `rate_limited` (429), `server_error` (5xx), `transport` | no | yes | the engine's answer or the network | ## Constants These are the values to reference instead of hard-coding, and they are covered by the promise: `Config::MAX_BATCH_URLS`, `Config::DEFAULT_BATCH_MAX_URLS`, `Config::DEFAULT_DEBOUNCE_PER_URL`, `Config::DEFAULT_THROTTLE_PER_MINUTE`, `Config::DEFAULT_HTTP_TIMEOUT`, `Config::PRODUCTION_ENVIRONMENTS`, `Config::OPTIONS`, `Result::NO_ENGINE`, `Client::FORBIDDEN_ESCALATION`, `Client::FAILURE_CACHE_TTL` (the lifetime of the 403 counter in the failure cache; `History\Adapter\HistoryServices::forbiddenCounter()` needs it), `Check\DebounceStoreCheck::PROBE_KEY` (the key every adapter's cache probe writes), `Check\LocalesCheck::CODE`, `KeyValidator::MIN_LENGTH`, `KeyValidator::MAX_LENGTH`, `KeyValidator::ALPHABET`, `KeyValidator::PATTERN`, `KeyFileResponder::PATH_PATTERN`, `KeyFileResponder::CONTENT_TYPE`, `KeyFileResponder::DEFAULT_MAX_AGE`, `Http\Response::MAX_RETRY_AFTER`, `Psr18Transport::POST_BODY_LIMIT`, `Psr18Transport::GET_BODY_LIMIT`, `UrlNormalizer::MAX_URL_LENGTH`, `UrlNormalizer::MAX_HOST_LENGTH`, `UrlNormalizer::MAX_LABEL_LENGTH`, `ParamExtractor::SELF`, `Version::VERSION`. The names an adapter writes instead of a string literal: the node names of `Adapter\Services::*` (`TRANSPORT`, `KEYS`, `NORMALIZER`, `THROTTLE`, `DEBOUNCE_STORE`, `CLIENT`, `SUBMITTER`, `COLLECTOR`, `DISPATCHER`, `READER`, `ROUTER`, `RESOLVER_LOCATOR`, `URL_RESOLVER`, `PARAM_EXTRACTOR`, `FAILURE_CACHE`, `SUBMISSION_STORE`, `CHANGES`, `CLOCK` — a container adapter maps them to its definitions), the dispatch modes the core itself knows (`Dispatch\DispatcherFactory::SYNC`, `::NONE`) and the two reserved values of `debounce.store` (`Debounce\DebounceStoreFactory::MEMORY`, `::NONE`). A node name is added when a node is added, which is a minor; none is renamed or removed before 1.0 without a "Changed" entry. Enums (`ResultStatus`, `Reason`, `Event`, `Engine`, `Check\CheckLevel`, `Attribute\RuleSource`, `Attribute\Param\Placeholder`) and the value objects of the rule model (`Attribute\UrlRule`, `RuleSet`, `RuleEvent`, `Attribute\Param\{Accessor, Value, Formatted, Call}` and the condition `Attribute\Param\Equals`, `Url\ResolvedUrl`) are public API: their public properties are read by adapters and their constructors only grow by appended optional parameters. Their **values** may change in a minor when the protocol or a safety limit changes; the constants themselves will not disappear. ## Exceptions Every exception implements `Exception\IndexNowException`, so `catch (IndexNowException $e)` is the stable form. `ConfigurationException`, `InvalidUrlException`, `InvalidArgumentException` and `Http\Exception\TransportException` keep their meanings. `ConfigurationException` and `InvalidUrlException` extend `Exception\InvalidArgumentException`, which extends PHP's `\InvalidArgumentException`, so both `catch (Exception\InvalidArgumentException)` and `catch (\InvalidArgumentException)` see them. Exception **messages** are not API: they are written for humans and get improved. Match on the class, or on `Result::$reason`, never on message text. ## What is not covered - Anything marked `@internal` in a docblock. Today that is `Config\ConfigParser` and `Config\ConfigNormalizer` (the readers and the normalisation behind `Config`; call `Config::fromArray()` / `fromEnv()`), `Url\Punycode`, `Transaction\StagingFrame`, `Attribute\IndexNow::normalizeEvents()`, `Collector::reportLeak()` and the constructor of `Adapter\Services` (built by `ServicesBuilder::build()`). - Private and protected members of `final` classes, which is all of them: the library has no inheritance points by design, only interfaces. - Log message texts. They are documented in [operations.md](operations.md) so you can grep them, and they are improved between versions. Alert on `Reason` values and log **levels**, not on wording. - Anything under `tests/`, including fixtures and the mock server copy. The published test doubles live in `IndexNowKit\Testing` and **are** covered. The conformance kits (`Testing\Conformance\CoreConformanceTestCase`, `OrmConformanceTestCase`) and the assertion helpers are the `indexnowkit/testing` package since 0.7.0, with their own [bc.md](../testing/bc.md): driver methods only grow by appended methods with a default implementation, a scenario is only added, never removed, in a minor. - The exact set of `Result` objects a single `submit()` call returns. Grouping by host and batching are implementation details of throughput; use `Result::allUrls()`, `Result::retryableUrls()` and `Result::urlsWhere()` instead of indexing into the list. ## Deprecations A deprecated member keeps working for at least one minor version, carries a `@deprecated` tag naming the replacement, and is listed in the changelog. Currently deprecated: | Since | Member | Use instead | |---|---|---| | 0.4.0 | `serve_key_file` (`Config::fromArray()`, `fromEnv()`: `INDEXNOW_SERVE_KEY_FILE`) | `key_file.enabled` / `INDEXNOW_KEY_FILE_ENABLED`; the explicit `serve_key_file` still wins while both exist | Removed after their deprecation window: `Result::urlsOf()` (deprecated 0.2.0, removed 0.4.0). Moved out of the core without a deprecation window (the pre-1.0 rule): in 0.4.0 `IndexNowKit::sitemap()` and everything under `Sitemap\`, now the `indexnowkit/sitemap` package; in 0.7.0 `Testing\Conformance\*` and the assertion helpers (`Testing\KeyFileAssertions`, `CheckOutputAssertions`, `ReadmeAssertions`, now `Testing\Conformance\*` in `indexnowkit/testing`) and everything under `Console\` except `SubmitterFactory*` (now `Adapter\`) and `ResultSummary` (now `Submission\`), now the `indexnowkit/console` package with the FQCN unchanged and its own [bc.md](../console/bc.md). ## Before 1.0 Minor versions may break. The changes made in 0.2.0, 0.4.0, 0.7.0 and 0.8.0 are listed in the changelog (0.5.0 and 0.6.0 were additive); the shape of the breakage to expect is the same: renamed classes as the namespace layout settles, and signatures on the "may grow" interfaces as more adapters land. Application code that only uses the facade, `Config`, the attributes and `Result` has been stable since 0.1 and is expected to stay so. If you need an extension point that does not exist, open an issue rather than reaching into `@internal` or copying a final class. Adapter-driven interface changes are exactly what the pre-1.0 window is for. # Compatibility What each `indexnowkit/*` package runs on, and how long that is meant to last. The constraints are the ones in the packages' `composer.json`; the dates are the upstream end-of-life dates at the time of writing (2026-09), linked so you can check them. The promise itself (what may change in a minor, what may not) is [bc.md](bc.md). ## Policy - **PHP.** Every package requires the PHP versions that still receive security fixes from php.net, and CI runs the whole matrix (8.2, 8.3, 8.4, 8.5) against the lowest and the highest dependency set. The minimum is raised in the first minor after the previous version leaves security support: `^8.2` becomes `^8.3` in the first minor released after 2026-12-31. **Raising the minimum PHP is not a breaking change** of the library: Composer does not offer the new minor to an application on the old PHP, and the code the application sees does not change. None of the packages declares an upper bound of its own; where the matrix stops short of the newest PHP, the bound comes from a dependency — the `yiisoft/*` packages of `indexnowkit/yii3` are the case today (they declare `8.1 - 8.5`), so an application on a newer PHP waits for them, not for us. - **Frameworks.** An adapter supports the framework versions that are in bug-fix or security support upstream, and drops a version in the first minor after its security support ends. A new major of a framework is added in a minor of the adapter when the test suite passes on it, without a release of the core. - **Between the packages.** Every package pins the core to one minor; the adapters pin `console`, `sitemap`, `verify`, `history` and `doctrine` the same way. A release wave moves the constraints together (see [the family changelog](https://github.com/indexnowkit/php/blob/main/CHANGELOG.md)), so `composer update indexnowkit/*` is the upgrade. ## Matrix | Package | PHP | Framework / library | Upstream support ends | |---|---|---|---| | `indexnowkit/core` | `^8.2` | PSR-18 client of your choice (`php-http/discovery`), PSR-3, PSR-16; PSR-7/PSR-17 and PSR-15 (`psr/http-server-handler`, `psr/http-server-middleware`) for `Key\KeyFileRequestHandler` — interfaces only, no implementation is required | — | | `indexnowkit/console` | `^8.2` | `symfony/console ^6.4 \|\| ^7.0 \|\| ^8.0` | 6.4: security fixes to 2027-11; 7.4 LTS: 2029-11 | | `indexnowkit/testing` | `^8.2` | PHPUnit `^11.5 \|\| ^12.0 \|\| ^13.0` (the conformance kits) | per [phpunit.de](https://phpunit.de/supported-versions.html); PHPUnit 11 left bug-fix support on 2026-02-06. The Laravel adapter's own suite stays on PHPUnit 11: `laravel/framework` 12/13 and PHPUnit 12.5 disagree on the error handler | | `indexnowkit/sitemap` | `^8.2` | `symfony/console ^6.4 \|\| ^7.0 \|\| ^8.0` for the command | as console | | `indexnowkit/verify` | `^8.2` | the core's transport; `symfony/console` for `check --sample` (suggested) | as console | | `indexnowkit/history` | `^8.2` | `ext-pdo` (sqlite, mysql, pgsql schemas), PSR-16; `symfony/console` for the commands | as console | | `indexnowkit/doctrine` | `^8.2` | `doctrine/orm ^2.19 \|\| ^3.0`, `doctrine/dbal ^3.8 \|\| ^4.0` | ORM 2.x / DBAL 3.x: security fixes only, see [doctrine-project.org](https://www.doctrine-project.org/projects.html) | | `indexnowkit/symfony-bundle` | `^8.2` | Symfony `^6.4 \|\| ^7.0 \|\| ^8.0` (`framework-bundle`, `http-kernel ^6.4.13`; Symfony 8 needs PHP 8.4), `doctrine/doctrine-bundle ^2.13 \|\| ^3.0` with `indexnowkit/doctrine` | 6.4 LTS: bug fixes to 2026-11, security to 2027-11; 7.4 LTS: 2028-11 / 2029-11 ([symfony.com/releases](https://symfony.com/releases)) | | `indexnowkit/laravel` | `^8.2` | `illuminate/support ^12.0 \|\| ^13.0` (Laravel 12, 13) | 12: bug fixes to 2026-08, security to 2027-02; 13: 2027-08 / 2028-02 ([laravel.com/docs/releases](https://laravel.com/docs/releases)) | | `indexnowkit/yii2` | `^8.2` | `yiisoft/yii2 ^2.0.45`; `yiisoft/yii2-queue ^2.3` for `dispatch: queue` | 2.0.x maintained, no end date announced ([yiiframework.com](https://www.yiiframework.com/release-cycle)) | | `indexnowkit/yii3` | `^8.2`, in practice up to 8.5 | `yiisoft/active-record ^1.0`, `yiisoft/db ^2.0`, `yiisoft/router ^4.0`; `yiisoft/config`, `yii-http`, `yii-console`, `yii-event` of the application read its config groups | Yii3 packages follow their own SemVer lines ([github.com/yiisoft](https://github.com/yiisoft)); every `yiisoft/*` in `require` declares `8.1 - 8.5`, so that is the real PHP ceiling of this adapter; no queue mode until `yiisoft/queue` is released | PHP itself: 8.2 security fixes to 2026-12-31, 8.3 to 2027-12-31, 8.4 to 2028-12-31, 8.5 to 2029-12-31 ([php.net/supported-versions](https://www.php.net/supported-versions.php)). Symfony 8 is a target of the bundle since 0.13 (CI: PHP 8.4 with `framework-bundle ^8.0`); `console`, `sitemap`, `history`, `yii2` and `yii3` accept `symfony/console ^8.0`. Laravel 11 and Symfony 6.3 and below are not supported. ## Flavours in CI `bin/ci ` runs the same install the workflow does: `highest` and `lowest` for every package, `dbal3` for `doctrine` (DBAL 3 / ORM 2), `symfony64` and `symfony8` for `symfony-bundle` (Symfony 6.4, and Symfony 8 on PHP 8.4, each with the highest of everything else). A change that passes `highest` but not `lowest` is a constraint bug, not a code bug: the fix is the constraint. # Codes of `check` Every line the check command prints carries a stable code (`Check\CheckItem::$code`). The code is what `check --json` consumers, deploy pipelines and alert rules match on; the **text is not API** and gets improved between versions, the same way `Reason` is the identifier of a `Result` and `Result::$error` the sentence. A code names the check, not the outcome: `key_file.status` is `ok` when the key file answers 200 with the right body and `error` when it does not, so a rule written as "fail the deploy when `key_file.status` is not ok" survives a rewording. Codes are added in minor versions when a check is added (a new line in the table below), and never renamed or removed before 1.0 without an entry under "Changed" in the changelog. A code is a dotted lower-case identifier; the first segment is the area. Lines about one host carry it in `CheckItem::$host` (`"host"` in the JSON), the global lines have `null` there. ## Core (`Check\Checker`) | Code | Levels | Line | |---|---|---| | `config.enabled` | warning | `enabled: false`: nothing will be submitted | | `config.dry_run` | warning, error | `dry_run` is on (error when the environment is production) | | `environment.name` | ok, warning | the `environment: …` line; warning outside production when real requests leave | | `environment.non_production_submits` | warning, error | a non-production environment with a key and `dry_run` off: error when `dry_run` was left unset, warning when it says `false` explicitly | | `config.strict_hosts` | ok, warning | `strict_hosts` on; or off next to a `hosts` map / in production | | `config.base_url` | ok, warning | `base_url` set or missing | | `config.engines` | ok | the resolved engine list | | `config.delivery` | ok | dispatch, debounce window, batch size, throttle, timeout | | `config.hosts` | error | no host to check at all (no `base_url`, no `hosts`) | | `http.client` | warning | a custom `http.client` fetches the key files: if it follows redirects, a 30x to a catch-all page looks like a 200 | | `key.missing` (host) | error | no key for the host | | `key.invalid` (host) | error | the key fails `KeyValidator` | | `key_file.location` (host) | error | `key_location` points to another host (engines answer 422) | | `key_file.served_externally` (host) | warning | `key_file.enabled: false` and no `key_location`: the web server must serve the file | | `key_file.status` (host) | ok, error | `GET /.txt`: ok on 200 with the key as body; error on any other status | | `key_file.body` (host) | error | 200 with a body that is not the key (a catch-all route) | | `key_file.fetch` (host) | error | the key file could not be fetched (network error, no HTTP client) | | `key_file.content_type` (host) | ok, warning, error | after a matching key file: `text/plain` ok; no `Content-Type` header warning; another type error; one neutral ok line when the transport exposes no headers | | `key_file.cache_control` (host) | ok, warning | after a matching key file: `Cache-Control` lifetime (`s-maxage`, else `max-age`) or `Age` above `key_file.cache_max_age` is a warning (a rotation would serve the old key for that long); absent header: no line | | `key_file.robots` (host) | ok, warning | `robots.txt` (when it answers 200): a `Disallow` covering the key file path for every bot or an engine's bot is a warning | | `key_file.previous` (host) | ok, warning | `previous_key` set: the old key file still answers 200 with the old key (ok: rotation window open), or not (warning) | | `probe.config` (host) | error | `--live`: the live configuration cannot be built | | `probe.response` (host) | ok, warning, error | `--live`: one line per engine: 200 ok, 202 warning (verification pending), anything else error | | `check.failed` | error | a registered `CheckInterface` threw; the line names the class | | `debounce.store` | ok, warning, error | `Check\DebounceStoreCheck`: off, `none`, `memory` (warning), a shared store probed ok, or unusable (error) | | `.installed` | ok, warning | `Adapter\OptionalPackage`: an optional package of the family is not installed (`sitemap.installed`, `verify.installed`, `history.installed`); warning when its block is configured and ignored. `verify.installed` also carries the `--sample` gate and is an **error** there, see "Optional packages" below | ## Adapters | Code | Package | Levels | Line | |---|---|---|---| | `wiring.messenger` | symfony-bundle | warning | `dispatch: messenger` without a routed transport | | `wiring.doctrine` | symfony-bundle | ok, warning | entity hooks active or not | | `queue.dispatch` | laravel, yii2 | ok | `dispatch` is not `queue`: what happens instead | | `dispatch.mode` | yii3 | ok, error | `sync` / `none`, or the `DispatcherInterface` the application replaced in the container; error when the dispatcher cannot be built | | `queue.connection` | laravel | error | the queue connection is not defined | | `queue.component` | yii2 | error | the yii2-queue component does not exist | | `queue.driver` | laravel, yii2 | ok, warning | the queue driver: `sync` (warning, nothing is retried) or a real one | | `eloquent.enabled` | laravel | ok, warning | model observers active or not | | `active_record.enabled` | yii2, yii3 | ok, warning, error | ActiveRecord hooks active or not; Yii3: error when the observer is not installed (the package's bootstrap did not run) | | `url_manager.key_file` | yii2 | ok, error | the key file is not served by the application, or `key_file` is misconfigured | | `url_manager.pretty_url` | yii2 | error | `enablePrettyUrl` is off, `/.txt` cannot be routed | | `url_manager.rule` | yii2 | ok, error | the key file URL rule is registered, or missing (component not in `bootstrap`) | | `router.key_file` | yii3 | ok, error | the key file is not served by the application (`key_file.enabled: false`), or `key_file` is misconfigured | | `router.route` | yii3 | ok, error | the route `indexnow/key-file` is in the route collection (or the console says the web application serves it), or missing (the `routes` group of the package is not merged) | | `router.locales` | symfony-bundle, laravel, yii2 (the core's `Check\LocalesCheck`) | ok, warning | a rule asks for `locales: 'all'` while the locale list of the application is empty, so one URL in the current locale is generated instead of one per locale (`framework.enabled_locales` in Symfony, `router.locales` in Laravel and Yii2): one text, the option and the classes named. The `ok` line naming the configured locales and the route parameter is written by the adapters that know the parameter (Laravel, Yii2); the bundle prints the warning only | ## Optional packages The line of an optional package that is not installed is `.installed` (above). With the package installed: | Code | Package | Levels | Line | |---|---|---|---| | `sitemap.spool` | sitemap | ok, warning, error | where sitemap documents are spooled; error when `spool: disk` has no writable directory | | `verify.installed` | verify, and the core's `Check\SampleGateCheck` | ok, warning, error | with the package: `verify: installed, disabled (verify.enabled: false)` or `verify: enabled (redirect: …, non_canonical: …, origin_error: …)`. Without the package the line comes from the gate the core ships in front of it: the `.installed` line above plus ` — pre-flight checks off` (warning when a `verify` block is configured and ignored), and **error** `check --sample needs indexnowkit/verify (composer require indexnowkit/verify)` when `--sample` or `--sample-class` was given. Every adapter used to carry a copy of that gate; it is one class in the core now | | `verify.dispatch` | verify | warning | `verify.enabled` with `dispatch: sync`: the pre-flight GETs run inside the web request; use a queue | | `verify.transport` | verify | ok | `verify.enabled` with an `http.client` of the application: the line says that the pre-flight does **not** use it — it builds its own PSR-18 client with `verify.timeout` and no redirects, because a client that follows redirects internally would hide the 3xx the pre-flight exists to see. `http.client` still sends the submissions | | `verify.sample` (host) | verify | ok, warning, error | one line per `--sample` / `--sample-class` URL: `verify sample {url}: HTTP 200, index, canonical: self, robots: allowed`; noindex, disallow, a foreign canonical, a redirect, a 4xx/5xx or a transport failure are **warnings**, never errors; with the package and no sample: ok `no sample given`. Without the package a sample is an error under `verify.installed`, not under this code — the code exists only while the package does | | `history.store` | history | ok, error | the configured store (`history: pdo store (indexnow_submissions)`, `history: psr16 store (500 records kept)`, `history: custom store ()`, `history: installed, no store configured (history.store)`); error with the exception and the migration hint when the store fails (a missing table) | | `history.records` | history | ok | `history: 1 240 records, last 3 min ago`, or `history: no records yet` | Application checks (`CheckInterface` implementations you register) choose their own codes; leave the core areas (`config`, `environment`, `key`, `key_file`, `probe`, `debounce`) to the core. A line without a code is allowed but appears as `"code": null` in the JSON. # Submission store `Submission\SubmissionStoreInterface` is where the `Submitter` remembers what it did: one record per `Result` after every `submit()`, written after the listeners and the PSR-14 event. The core ships the interface, the value object `Submission\SubmissionRecord` (`urls`, `result`, `at`) and `Submission\NullSubmissionStore`, which keeps nothing and is what every adapter wires by default. [`indexnowkit/history`](../history/index.md) brings the two stores every adapter can wire with one option (`history.store: psr16` — a ring buffer in the debounce cache, `history.store: pdo` — a table, migration in the package's `docs/migrations.md`), the `history` and `status` commands (`--json`), the `history.store` / `history.records` lines of `check` and, in Symfony, a "Recent submissions" table in the profiler; its `HistoryStoreInterface` extends this one with `count()`, `last()` and `purge()`. A store of your own still plugs into the same point and the commands work with it (`recent()` only). ## Wiring | Where | How | |---|---| | plain PHP | `IndexNowKit::create($config, submissionStore: $store)` or `new Submitter(..., store: $store, clock: $clock)` | | `Adapter\ServicesBuilder` | `->submissionStore($store)` (an instance or a `Closure(Services): SubmissionStoreInterface`) | | Symfony bundle | replace the service `indexnowkit.submission_store` (alias `Submission\SubmissionStoreInterface`) | | Laravel | `$this->app->singleton(SubmissionStoreInterface::class, MyStore::class)` after the provider | | Yii2 | component property `submissionStore` (instance, class name, configuration array or component id) | The console submitters (`submit --force`, `--dry-run`, the sitemap command) record through the same store. ## What becomes a record | Situation | Records | |---|---| | one URL, `engines: ['api']`, 200 | 1 record, `status: ok`, `engine: api` | | one URL, `engines: ['api', 'yandex']` | 2 records, one per engine; `lastFor($url)` returns the later one, whatever its status | | 10 000 + 1 URLs, one engine | 2 records (one per batch of `batch.max_urls`) | | a URL of another host next to a URL of `base_url` | 2 records (one per host) | | `dry_run: true` | 1 record per engine × batch, `status: skipped`, `reason: dry_run`, the engine it would have reached | | `enabled: false`, a debounced URL, an unmanaged host, an invalid URL | 1 record per host (per URL for an invalid one), `status: skipped`, `engine: none` (`Result::NO_ENGINE`) | | 429 / 5xx / network failure | 1 record, `status: failed`, `retryable: true`; the retry of a queue job writes its own record later | | a listener throws | nothing changes: listeners are called before the store and are isolated | Every record gets the same `at`: the Submitter's clock (`Psr\Clock\ClockInterface`, `Clock\SystemClock` by default) read once per `submit()`. ## Contract for an implementation - `record()` must not throw. If it does, the Submitter logs `indexnow: submission store failed, {count} result(s) not recorded: {error}` once for the call and delivery is not affected. - `recent()` returns the newest records first; `host` and `status` are filters, both optional. - `lastFor($url)` matches the URL as it is stored in `Result::$urls`: normalized for everything that reached the pipeline, as given for an invalid URL (`reason: invalid_url`). An index from URL to record is the store's job; a linear scan is fine for a ring buffer of a few hundred entries. - Tier Implement ([bc.md](bc.md)): the core calls you, methods are not added in a minor. Before 1.0 the interface is new and may still move; it has to live through one minor unchanged before 1.0 is tagged. # IndexNow console runners — `indexnowkit/console` The bodies of the `check`, `submit`, `submit-`, `explain` and `key:generate` commands every framework adapter of the family ships (`bin/console indexnow:check`, `php artisan indexnow:check`, `php yii indexnow/check`), and the one declaration of their arguments and options. An adapter's command is input parsing over a runner from this package; every framework prints the same thing, and an application reuses a runner from its own command (a tenant loop over `SubmitSubjectsRunner` is a ten-line command). Split out of [`indexnowkit/core`](../core/index.md) in core 0.7 so the core no longer imports `symfony/console`; the FQCN (`IndexNowKit\Console\*`) are unchanged. [![Packagist](https://img.shields.io/packagist/v/indexnowkit/console)](https://packagist.org/packages/indexnowkit/console) [![Downloads](https://img.shields.io/packagist/dt/indexnowkit/console)](https://packagist.org/packages/indexnowkit/console) [![CI](https://github.com/indexnowkit/php/actions/workflows/ci.yml/badge.svg)](https://github.com/indexnowkit/php/actions) ![PHPStan](https://img.shields.io/badge/phpstan-level%209-4c1) ![PHP](https://img.shields.io/badge/php-%5E8.2-777bb4) [![License](https://img.shields.io/packagist/l/indexnowkit/console)](https://github.com/indexnowkit/php/blob/main/packages/console/LICENSE) [Русская версия](https://github.com/indexnowkit/php/blob/main/packages/console/README.ru.md) · Issues and pull requests: [github.com/indexnowkit/php](https://github.com/indexnowkit/php/issues) (the `php-*` repositories are read-only splits) ## Install ```bash composer require indexnowkit/console # brings indexnowkit/core and symfony/console ^6.4 || ^7.0 || ^8.0 ``` With a framework adapter you install nothing: `indexnowkit/symfony-bundle`, `indexnowkit/laravel` and `indexnowkit/yii2` and `indexnowkit/yii3` require this package and register the commands. `indexnowkit/sitemap` builds its `sitemap` command on it too. ## What is inside | Command | Runner | What the adapter supplies | |---|---|---| | `check` | `Console\CheckRunner` | a closure that builds `Config` from the raw configuration (throws `ConfigurationException`); `Check\CheckInterface` services for adapter wiring and the add-on packages | | `submit ...` | `Console\SubmitRunner` | — | | `submit- [ids]` | `Console\SubmitSubjectsRunner` + `SubmitSubjectsOptions` | a `Console\SubjectLoaderInterface`: class resolution (FQCN or the framework's short name), objects by id, first N objects | | `explain ` | `Console\ExplainRunner` | the same loader | | `key:generate` | `Console\KeyGenerateRunner` | the default env file path | Every runner renders to a `Symfony\Component\Console\Style\SymfonyStyle` (Laravel's `OutputStyle` is one) and returns a `Console\ExitCode`. Shared by all of them: `Console\Definitions` (the arguments and options of every command, declared once — `CommandDefinition`, `ArgumentDefinition`, `OptionDefinition` — and rendered by the adapter into its framework's command), `Console\ResultFormatterInterface` (`ResultRenderer`: the table or `--json`; an application replaces it to match its own CLI), `Console\Vocabulary` (the words that differ between frameworks: "entity" / "model" / "record", `bin/console` / `php artisan` / `php yii`, where the configuration lives), `Console\ClassNameResolver` (a short class name to a FQCN, with the error texts). The submitters the commands use for `--force` / `--dry-run` (`Adapter\SubmitterFactory`) and the aggregate of a batched run (`Submission\ResultSummary`) stay in the core: they are not CLI concerns. ## Commands: how an application on symfony/console registers them Since 0.5.0 the commands are classes of this package (`IndexNowKit\Console\Command\*`), the ones the Symfony bundle and the Yii3 package register. An application with a `Symfony\Component\Console\Application` of its own registers them the same way: build the runners, hand each command what varies by constructor — nothing here knows a framework or a container. ```php use IndexNowKit\Check\Checker; use IndexNowKit\Check\SampleOptions; use IndexNowKit\Config; use IndexNowKit\Console\CheckRunner; use IndexNowKit\Console\Command\CheckCommand; use IndexNowKit\Console\Command\ConfigCommand; use IndexNowKit\Console\Command\KeyGenerateCommand; use IndexNowKit\Console\Command\SubmitCommand; use IndexNowKit\Console\ConfigRunner; use IndexNowKit\Console\ConfigSourceInterface; use IndexNowKit\Console\KeyGenerateRunner; use IndexNowKit\Console\SubmitRunner; use IndexNowKit\Console\Vocabulary; use IndexNowKit\IndexNowKit; use Symfony\Component\Console\Application; final class EnvConfigSource implements ConfigSourceInterface // what check and config read { public function raw(): array { return Config::fromEnv()->toArray(); } public function build(): Config { return Config::fromEnv(); } // throws ConfigurationException when invalid public function packages(): array { return []; } // the blocks of the installed optional packages } $indexNow = IndexNowKit::create(Config::fromEnv()); $words = new Vocabulary(cli: 'bin/indexnow', configLocation: 'the INDEXNOW_* env vars'); $submitters = $indexNow->submitterFactory(); // --force / --dry-run build their own submitter $application = new Application('indexnow'); $application->addCommands([ new CheckCommand(new CheckRunner(new Checker($indexNow->config, $indexNow->keys, $indexNow->transport), $words), new EnvConfigSource(), new SampleOptions()), new ConfigCommand(new ConfigRunner($words), new EnvConfigSource()), new SubmitCommand(new SubmitRunner($indexNow, $submitters)), new KeyGenerateCommand(new KeyGenerateRunner($words), envFileName: '.env'), // --write-env without a value: /.env ]); $application->run(); ``` `SubmitSubjectsCommand` (`indexnow:submit-`, its name is `Vocabulary::$submitSubjects`) and `ExplainCommand` need a `SubjectLoaderInterface` — the ORM of the application — and are registered by the adapters that have one. `indexnow:sitemap` is `IndexNowKit\Sitemap\Console\SitemapCommand` of `indexnowkit/sitemap`, `indexnow:history` and `indexnow:status` are `IndexNowKit\History\Console\HistoryCommand` / `StatusCommand` of `indexnowkit/history`; without the package, `Command\SitemapNotInstalledCommand`, `HistoryNotInstalledCommand` and `StatusNotInstalledCommand` stand in under the same names with the install line and exit 1. In a Symfony container `SubmitSubjectsCommand` is registered lazily with the `command` and `description` attributes of the `console.command` tag (its name is not an attribute of the class); every other command carries `#[AsCommand]` and is lazy on its own. ## Plain PHP ```php use IndexNowKit\Check\Checker; use IndexNowKit\Config; use IndexNowKit\Console\CheckRunner; use IndexNowKit\Console\Vocabulary; use IndexNowKit\IndexNowKit; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Style\SymfonyStyle; $indexNow = IndexNowKit::create(Config::fromEnv()); $io = new SymfonyStyle(new ArrayInput([]), new ConsoleOutput()); $runner = new CheckRunner(new Checker($indexNow->config, $indexNow->keys, $indexNow->transport), new Vocabulary()); exit($runner->run($io, static fn(): Config => Config::fromEnv(), live: true)); ``` No framework at all? Install the CLI instead of writing this: [`indexnowkit/cli`](../cli/index.md) (`composer global require indexnowkit/cli`, a PHAR, a Docker image) registers these classes over `INDEXNOW_*` variables and a state file. Writing an adapter? [core/docs/adapters.md §14](../core/adapters.md) walks through the six commands; the bundle, the Laravel package, the Yii2 component and the Yii3 package are the reference wirings. ## Requirements PHP 8.2+, `indexnowkit/core ^0.13`, `symfony/console ^6.4 || ^7.0 || ^8.0`. ## Notes for AI assistants - Composer package `indexnowkit/console`: the command bodies (`IndexNowKit\Console\*Runner`), the command definitions (`IndexNowKit\Console\Definitions`) and, since 0.5.0, the symfony/console command classes themselves (`IndexNowKit\Console\Command\*`: `check`, `config`, `submit`, `submit-entity` / `submit-record`, `explain`, `key:generate`, the three "not installed" stubs) that the Symfony bundle, the Laravel package (artisan runs any symfony/console command; `submit-model` through a `LazyCommand`, the class argument named `model`) and the Yii3 package register; Yii2 (a `yii\console\Controller`) builds its actions on the runners. Since 0.5.0 also `Console\SubjectSampler` (the `--sample-class` sampler) and `Console\AbstractSubjectLoader` (the skeleton of an ORM loader). Framework users install an adapter, not this package. - Minimal complete snippet (every `use` included) — an application command over a runner: ```php use IndexNowKit\Console\SubmitRunner; use IndexNowKit\IndexNowKit; use Symfony\Component\Console\Style\SymfonyStyle; final class ReannounceCommand { public function __construct(private SubmitRunner $runner, private IndexNowKit $indexNow) {} public function run(SymfonyStyle $io): int { return $this->runner->run($io, ['https://www.example.com/pricing'], force: true, dryRun: false, json: false); } } ``` - Verify: the adapter's `check` command (`bin/console indexnow:check`, `php artisan indexnow:check`, `php yii indexnow/check`) is `CheckRunner`; every runner returns an `ExitCode` (`SUCCESS` 0, `FAILURE` 1, `INVALID` 2 for bad input) and never throws for remote errors. - Pitfalls: - Before core 0.7 these classes lived in `indexnowkit/core` with the same FQCN; only `Console\SubmitterFactory` (now `IndexNowKit\Adapter\SubmitterFactory`) and `Console\ResultSummary` (now `IndexNowKit\Submission\ResultSummary`) changed their namespace. - Option and argument names come from `Definitions` (`--force`, `--dry-run`, `--json`, `--live`, `--host`, `--probe-url`, `--limit`, `--event`, `--write-env`, `--length`): an adapter's command must not declare its own copies. - `--force` re-announces URLs inside the debounce window; `--dry-run` logs the request instead of sending it (`dry_run` in the configuration does the same for every submission). - Manual submission is `submitEntity()` in Symfony, `submitModel()` in Laravel, `submitRecord()` in Yii2 and Yii3; the commands are `indexnow:submit-entity`, `indexnow:submit-model`, `indexnow/submit-record` (Yii2), `indexnow:submit-record` (Yii3). - `dispatch: auto` exists in Symfony (`auto` | `messenger` | `sync` | `none`) and Yii2 (`auto` | `queue` | `sync` | `none`), **not** in Laravel (`queue` | `sync` | `none`); Yii3 has `sync` | `none` only. ## Versioning SemVer; until 1.0 minor versions may contain breaking changes, listed in [CHANGELOG.md](https://github.com/indexnowkit/php/blob/main/packages/console/CHANGELOG.md). What the compatibility promise covers: [docs/bc.md](bc.md). MIT. IndexNow is a trademark of its owner; this project is independent and not affiliated with Microsoft, Yandex or indexnow.org. # Backward compatibility `indexnowkit/console` follows SemVer and the tiers of the core's [docs/bc.md](../core/bc.md). **Before 1.0, minor versions may contain breaking changes**, listed under "Changed" in [CHANGELOG.md](https://github.com/indexnowkit/php/blob/main/packages/console/CHANGELOG.md). | Tier | Members | |---|---| | **Call** — signatures only grow by appended, defaulted parameters; pass anything past the first argument by name | `CheckRunner`, `ConfigRunner`, `SubmitRunner`, `SubmitSubjectsRunner`, `ExplainRunner`, `KeyGenerateRunner` (constructors and `run()`), `ResultRenderer`, `Vocabulary` (constructor: named arguments), `ClassNameResolver`, `SubjectSampler` (the `--sample-class` sampler over the adapter's loader; `PER_CLASS`), `Definitions::*` | | **Commands** — `final` classes over the runners, registered by an adapter on symfony/console; constructors take named arguments and grow only by appended, defaulted parameters | `Command\SubmitCommand`, `Command\SubmitSubjectsCommand`, `Command\ExplainCommand`, `Command\CheckCommand`, `Command\ConfigCommand`, `Command\KeyGenerateCommand`, `Command\SitemapNotInstalledCommand`, `Command\HistoryNotInstalledCommand`, `Command\StatusNotInstalledCommand`. The **name** of each is a contract (`#[AsCommand]`, or `Vocabulary::$submitSubjects` for `SubmitSubjectsCommand`); the description is not. `SubmitSubjectsCommand` and `ExplainCommand` take `string $classArgument = 'class'` (since 0.5.0): the name of the class argument as the adapter's command always called it (`model` in Laravel) — positional on the command line either way. `Command\NotInstalledCommand` is the one abstract class of the package: extend it for a stub of your own optional command, the message is its only constructor argument | | **Implement** — methods are not added without a major version | `SubjectLoaderInterface`, `ResultFormatterInterface`, `ConfigSourceInterface` (new in 0.5.0: before 1.0 a method may still be appended in a minor, listed under "Changed"), `AbstractSubjectLoader` (new in 0.5.0: the skeleton of an ORM loader — `findOne()` and `findMany()` are what you implement, `resolveClass()`, `byIds()`, `all()` and `guard()` are final; a protected method is not added without a major version) | | **Value objects** — `final readonly`, properties only appended with defaults | `CommandDefinition`, `ArgumentDefinition`, `OptionDefinition`, `SubmitSubjectsOptions` | | **Constants** — referenced, not hard-coded | `ExitCode::SUCCESS`, `FAILURE`, `INVALID`, `OptionDefinition::FLAG`, `VALUE`, `OPTIONAL_VALUE`, `LIST`, `CheckRunner::CONFIG_INVALID` | | **Documents** — the shape only grows by optional members | `docs/check.schema.json`, the JSON of `check --json` (`status`, `environment`, `items[].{level, code, message, host}`); the codes are the core's `docs/check-codes.md` | **Command surface.** The argument and option names, defaults and descriptions in `Definitions` are what the adapters render into their commands, so they are the public API of every adapter's CLI: an option is renamed only with a deprecation window on the adapter side. `CommandDefinition::laravelSignature()` is gone in 0.5.0 (artisan registers the command classes of this package since Laravel 0.15; the renderer had no other consumer). Descriptions and the printed texts of the runners are not API (they are written for humans and get improved); exit codes are. Not covered: log and exception message texts, anything under `tests/`. The package pins `indexnowkit/core ^0.13`: the runners take the core's `Config`, `Checker`, `Adapter\SubmitterFactoryInterface` and `Submission\ResultSummary`, so a core minor that changes them ships with a `console` minor. # IndexNow test kit — `indexnowkit/testing` The test suite every part of the family shares, as a `require-dev` package: the conformance scenarios of the specification as abstract PHPUnit cases you extend against *your* wiring (C01–C22 for anything that talks to the protocol, A01–A21 for an ORM adapter), the assertions of the HTTP and command scenarios (H01–H05) so a framework test parses its own response object and asserts once, an assertion for the README section AI assistants read, and the mock IndexNow server for end-to-end runs. It is what `indexnowkit/doctrine`, `indexnowkit/symfony-bundle`, `indexnowkit/laravel`, `indexnowkit/yii2` and `indexnowkit/yii3` test themselves with; an adapter for another framework starts here. The four test doubles (`FakeTransport`, `ArrayLogger`, `FrozenClock`, `RecordingDispatcher`) stay in [`indexnowkit/core`](../core/index.md) under `IndexNowKit\Testing`: they implement core interfaces and need no PHPUnit, so an application test suite gets them without this package. [![Packagist](https://img.shields.io/packagist/v/indexnowkit/testing)](https://packagist.org/packages/indexnowkit/testing) [![Downloads](https://img.shields.io/packagist/dt/indexnowkit/testing)](https://packagist.org/packages/indexnowkit/testing) [![CI](https://github.com/indexnowkit/php/actions/workflows/ci.yml/badge.svg)](https://github.com/indexnowkit/php/actions) ![PHPStan](https://img.shields.io/badge/phpstan-level%209-4c1) ![PHP](https://img.shields.io/badge/php-%5E8.2-777bb4) [![License](https://img.shields.io/packagist/l/indexnowkit/testing)](https://github.com/indexnowkit/php/blob/main/packages/testing/LICENSE) [Русская версия](https://github.com/indexnowkit/php/blob/main/packages/testing/README.ru.md) · Issues and pull requests: [github.com/indexnowkit/php](https://github.com/indexnowkit/php/issues) (the `php-*` repositories are read-only splits) ## Install ```bash composer require --dev indexnowkit/testing # brings indexnowkit/core; PHPUnit 11 is expected in your require-dev ``` Everything lives under `IndexNowKit\Testing\Conformance\`. ## Conformance kits Three abstract test cases turn [docs/spec/03](https://github.com/indexnowkit/spec/blob/main/03-conformance.md) into runnable scenarios against the facade your container built: ```php use IndexNowKit\IndexNowKit; use IndexNowKit\Testing\Conformance\CoreConformanceTestCase; use IndexNowKit\Testing\FakeTransport; final class CoreConformanceTest extends CoreConformanceTestCase { protected function kit(): IndexNowKit { return $this->container()->get(IndexNowKit::class); } protected function transport(): FakeTransport { return $this->container()->get(FakeTransport::class); } protected function secondHost(): ?string { return 'example.de'; } // a second entry of `hosts`, or null to skip C04 } ``` - `CoreConformanceTestCase` (C01, C03, C04, C06, C09–C12, C14, C19, C20): return the facade and the `FakeTransport` it is wired to; the scenarios use fresh URLs, so the debounce window is irrelevant. - `SubmissionStoreConformanceTestCase` (S01–S08): return a fresh `Submission\SubmissionStoreInterface` from `createStore()` (`supportsPurge()` when it has the `purge()` of `indexnowkit/history`): S01 record and read back, S02 newest first, S03 host filter, S04 status filter, S05 `lastFor()`, S06 limit, S07 several URLs of one Result, S08 empty store and `purge()`. - `OrmConformanceTestCase` (A01–A21, plus A05b/A05c): implement the driver — the transaction verbs of your data layer (`begin()`, `commit()`, `rollback()`), the end of a unit of work (`flush()`, `collectedCount()`), and fixtures with fixed rule shapes (`createPost()`, `createMultiPost()`, `createCategorizedPost()`, `createTag()`, `attachTag()`, `bulkUpdateTitle()`, …). The docblock of the class lists the rules every fixture must carry; the URL conventions (`postUrl()`, `ampUrl()`, `categoryUrl()`, `homeUrl()`) are overridable. `indexnowkit/doctrine` (`tests/OrmConformanceTest.php`) and `indexnowkit/laravel` (`tests/Conformance/`) are the reference drivers. A scenario that does not apply to your framework is documented in your README, not skipped silently. The scenario identifiers are a cross-language contract: a scenario is added, never renumbered. ## Assertions for HTTP and command tests The scenarios H01–H05 are the same in every framework, only the way a response or a command output is captured differs. Parse your framework's objects, assert here: ```php use IndexNowKit\Testing\Conformance\CheckOutputAssertions; use IndexNowKit\Testing\Conformance\KeyFileAssertions; // H01: 200, text/plain, the key as the body, Cache-Control with public and max-age, Vary: Host only with a hosts map KeyFileAssertions::assertKeyFileResponse($response->getStatusCode(), $response->headers->all(), $response->getContent(), $key, maxAge: 300, expectVaryHost: true); // H02/H03: an unknown key, another host's key, key_file.enabled: false KeyFileAssertions::assertNotServed($response->getStatusCode()); // H04/H05: the check command CheckOutputAssertions::assertExitCode(0, $exitCode, $output); // the output is the failure message CheckOutputAssertions::assertReady($output, 'www.example.com'); // ": key file OK" and the closing line CheckOutputAssertions::assertKeyFileHint($output, 403); // the status and the hint about what the engines do ``` `Cache-Control` is compared by directive (frameworks order them differently), header names in any case, values as a string or a list. The phrases are the ones the core's `Checker` and the `check` command print, so your test does not carry a copy of them. `ReadmeAssertions::assertAiNotes($packageDir, $commands, $optionKeys)` checks the "Notes for AI assistants" section of a package README (EN and RU): present, with a PHP snippet that carries its `use` lines, naming only commands of the family and configuration keys the package accepts. Every package of the family runs it; an adapter of yours can too. `OptionalPackageAssertions::assertDetected($checkOutput)` checks what `check` prints about `indexnowkit/sitemap`, `indexnowkit/verify` and `indexnowkit/history` when the adapter leaves the predicates to detection: a `: not installed` line with the install command for every package that is really absent, and no such line for one that is present. Under `INDEXNOWKIT_OPTIONAL_PACKAGES=absent` (the `optional-packages-absent` CI job, which physically removes the three packages) the absence itself becomes an assertion — an adapter that loads a class of the package to decide whether it is installed passes with the package in `vendor/` and is a fatal error without it. ## The mock IndexNow server For end-to-end runs through a real PSR-18 client, without touching the engines: ```bash php -S 127.0.0.1:8089 vendor/indexnowkit/testing/resources/mock-server/router.php ``` Point `engines` at `http://127.0.0.1:8089/indexnow` (plain HTTP is accepted on loopback hosts only) and pick the behaviour with the `X-Mock-Scenario` header or `?scenario=`: `ok200` (default), `pending202`, `bad400`, `forbidden403`, `unprocessable422`, `ratelimit429` (`Retry-After: 2`), `ratelimit429-then-ok` and `flaky500-then-ok` (`?n=` failures first), `timeout`. The server validates the body like the real endpoint (host, key, `urlList`, at most 10 000 URLs, every URL on the declared host → 422 otherwise), serves `GET /{key}.txt` for the keys listed in the `MOCK_KEYS` environment variable (comma separated), and logs every request: `GET /_mock/requests` returns the log as JSON, `DELETE /_mock/requests` clears it. Start it from a test with `proc_open` on a free port, as the core's `Psr18TransportTest` does. ## Requirements PHP 8.2+, `indexnowkit/core ^0.7`, PHPUnit 11 in your `require-dev` (the test cases extend `PHPUnit\Framework\TestCase`). ## Notes for AI assistants - Composer package `indexnowkit/testing`, `require-dev` only: conformance test cases and assertions for a test suite that uses `indexnowkit/core` or one of its adapters; nothing here runs in an application. - Minimal complete snippet (every `use` included) — an adapter's conformance test: ```php use IndexNowKit\IndexNowKit; use IndexNowKit\Testing\Conformance\CoreConformanceTestCase; use IndexNowKit\Testing\FakeTransport; final class CoreConformanceTest extends CoreConformanceTestCase { protected function kit(): IndexNowKit { return $this->app->get(IndexNowKit::class); } // the facade the container built protected function transport(): FakeTransport { return $this->app->get(FakeTransport::class); } // the transport it is wired to } ``` - Verify: `vendor/bin/phpunit` runs the scenarios; a red C-scenario is a wiring problem in the adapter, not in the kit. - Pitfalls: - The test doubles (`FakeTransport`, `ArrayLogger`, `FrozenClock`, `RecordingDispatcher`) are `IndexNowKit\Testing\*` in the core; the kits and assertions are `IndexNowKit\Testing\Conformance\*` here. Before core 0.7 the assertions lived in the core under `IndexNowKit\Testing\*`. - `assertKeyFileResponse()` expects `Vary: Host` only when the application serves several hosts (a `hosts` map) and refuses it otherwise. - `CheckOutputAssertions::assertExitCode()` takes the whole output as its third argument so a failing test shows what the command printed. - The mock server accepts plain HTTP only on loopback hosts; `engines` must name the full endpoint (`http://127.0.0.1:8089/indexnow`). - `dispatch: auto` exists in Symfony (`auto` | `messenger` | `sync` | `none`) and Yii2 (`auto` | `queue` | `sync` | `none`), **not** in Laravel (`queue` | `sync` | `none`): a conformance test of an adapter runs with `dispatch: sync`. ## Versioning SemVer; until 1.0 minor versions may contain breaking changes, listed in [CHANGELOG.md](https://github.com/indexnowkit/php/blob/main/packages/testing/CHANGELOG.md). What the compatibility promise covers: [docs/bc.md](bc.md). MIT. IndexNow is a trademark of its owner; this project is independent and not affiliated with Microsoft, Yandex or indexnow.org. # Backward compatibility `indexnowkit/testing` follows SemVer and the tiers of the core's [docs/bc.md](../core/bc.md). **Before 1.0, minor versions may contain breaking changes**, listed under "Changed" in [CHANGELOG.md](https://github.com/indexnowkit/php/blob/main/packages/testing/CHANGELOG.md). | Tier | Members | |---|---| | **Call** — signatures only grow by appended, defaulted parameters; pass anything past the first argument by name | `KeyFileAssertions::*`, `CheckOutputAssertions::*`, `ReadmeAssertions::*` and their constants (`SECTION_EN`, `SECTION_RU`, `FAMILY_COMMANDS`, `CROSS_ADAPTER_KEYS` grow, never shrink) | | **Extend** — the abstract driver methods of a kit only grow by appended methods with a default implementation; a scenario is only added, never removed or renumbered, in a minor | `CoreConformanceTestCase`, `OrmConformanceTestCase`, `SubmissionStoreConformanceTestCase` | | **Resource** — the file path and the scenario names are stable; scenarios are added, not renamed | `resources/mock-server/router.php` (`X-Mock-Scenario`: `ok200`, `pending202`, `bad400`, `forbidden403`, `unprocessable422`, `ratelimit429`, `ratelimit429-then-ok`, `flaky500-then-ok`, `timeout`; `GET /_mock/requests`, `DELETE /_mock/requests`; `MOCK_KEYS`) | The conformance identifiers (C01–C22, A01–A21, H01–H06) are a cross-language contract of the specification: they are frozen at 1.0 of the family. What an assertion *accepts* may become stricter in a minor when the specification does (listed in the changelog); what it *rejects* never becomes accepted silently. Not covered: the failure-message texts of the assertions, anything under `tests/`. The package pins `indexnowkit/core ^0.7`: it reads `Config::OPTIONS` and the test doubles of the core, so a core minor that renames them ships with a `testing` minor. # IndexNow sitemap reader — `indexnowkit/sitemap` Re-announce a site's URLs to Yandex, Bing and the other [IndexNow](https://www.indexnow.org) engines from its own sitemap: a sitemap index, gzip-compressed and text sitemaps are streamed entry by entry and submitted in batches, so a million-URL sitemap never lives in memory. The `sitemap` command of every framework adapter of the family (`indexnowkit/symfony-bundle`, `laravel`, `yii2`, `yii3`) is this package; in plain PHP it is three lines over [`indexnowkit/core`](../core/index.md). **Google: no.** Google does not support IndexNow, its sitemap ping endpoint is gone and the Indexing API is limited to `JobPosting` / `BroadcastEvent`. Keep your sitemap for Google; this package announces it to the IndexNow engines only. IndexNow is a notification, not indexing: the engine decides whether and when to crawl. A run without `--changed-since` re-announces the whole sitemap: do that once, then schedule `--changed-since "1 day"`. `--changed-since` relies on ``; a generator that writes `lastmod = now()` for every URL turns every run into a full run, and entries without `lastmod` are skipped when the option is set. [![Packagist](https://img.shields.io/packagist/v/indexnowkit/sitemap)](https://packagist.org/packages/indexnowkit/sitemap) [![Downloads](https://img.shields.io/packagist/dt/indexnowkit/sitemap)](https://packagist.org/packages/indexnowkit/sitemap) [![CI](https://github.com/indexnowkit/php/actions/workflows/ci.yml/badge.svg)](https://github.com/indexnowkit/php/actions) ![Coverage](https://img.shields.io/badge/coverage-%E2%89%A5%2090%25%20enforced-brightgreen) ![PHPStan](https://img.shields.io/badge/phpstan-level%209-4c1) ![PHP](https://img.shields.io/badge/php-%5E8.2-777bb4) [![License](https://img.shields.io/packagist/l/indexnowkit/sitemap)](https://github.com/indexnowkit/php/blob/main/packages/sitemap/LICENSE) [Русская версия](https://github.com/indexnowkit/php/blob/main/packages/sitemap/README.ru.md) · Issues and pull requests: [github.com/indexnowkit/php](https://github.com/indexnowkit/php/issues) (the `php-*` repositories are read-only splits) ## Install ```bash composer require indexnowkit/sitemap # brings indexnowkit/core; needs ext-xmlreader, ext-zlib for .gz ``` With a framework adapter you install nothing: the adapter requires this package and registers the command (`bin/console indexnow:sitemap`, `php artisan indexnow:sitemap`, `php yii indexnow/sitemap`) with its `sitemap` configuration block. ## Plain PHP ```php use IndexNowKit\Config; use IndexNowKit\IndexNowKit; use IndexNowKit\Sitemap\SitemapConfig; use IndexNowKit\Sitemap\SitemapReader; $kit = IndexNowKit::create(Config::fromEnv()); $reader = SitemapReader::fromConfig(SitemapConfig::fromArray(['spool' => 'auto']), $kit->transport); $batch = []; foreach ($reader->read('https://www.example.com/sitemap.xml', new DateTimeImmutable('-1 day')) as $entry) { $batch[] = $entry->url; if (\count($batch) === $kit->config->batchMaxUrls) { $kit->submit($batch); $batch = []; } } $kit->submit($batch); ``` `read()` yields `SitemapEntry` objects (`url`, `lastmod`), optionally only those whose `` is newer than `$changedSince` (entries without `lastmod` are then skipped). The root may be an http(s) URL, a local path or a `file://` URL; nested sitemaps of an index are fetched over the transport you pass (the one the facade submits through, so `http.client` and `http.timeout` apply). `$kit->transport` is `null` when the facade was built around a custom submitter: use `Http\TransportFactory::lazy($kit->config)` then. ## Configuration `SitemapConfig::fromArray()` reads the `sitemap` block every adapter exposes; `SitemapConfig::OPTIONS` lists its dotted keys for `Config::unknownOptions()`. | Key | Default | | |---|---|---| | `sitemap.enabled` | `true` | `false`: the adapter registers no command and no reader | | `sitemap.url` | `null` | sitemap read when the command gets no argument; `null` = `/sitemap.xml` | | `sitemap.max_depth` | `3` | levels of `` followed below the root (`0` = the root only) | | `sitemap.max_sitemaps` | `1000` | documents fetched per run, root included | | `sitemap.max_bytes` | `52428800` | size cap of one uncompressed document (50 MiB, the protocol maximum; at least 1024) | | `sitemap.allow_foreign_hosts` | `false` | follow nested sitemaps on other origins (CDN-hosted parts); `--allow-foreign-hosts` enables it for one run | | `sitemap.spool` | `auto` | where a document is kept while parsing: `auto` = temp file, memory when the temp dir is not writable; `disk` = temp file or fail; `memory` | | `sitemap.spool_dir` | `null` | directory of the temp files (`sys_get_temp_dir()`); point it at a writable volume on a read-only filesystem | | `sitemap.fetch_retries` | `2` | extra attempts (1 s, 2 s, 4 s apart) after a network failure or 5xx while fetching a document; 4xx and broken documents are never retried | ## How it stays safe and small Memory stays flat whatever the sitemap size: every document is spooled (`Sitemap\Spool`: a temp file, or memory on a read-only filesystem; straight from the socket when the transport implements `Http\StreamingTransportInterface`, as `Psr18Transport` does), gzip is inflated chunk by chunk into a second spool, and `XMLReader` walks the spool through the `indexnowkit-spool://` wrapper with a few KiB of buffers. Nested sitemaps must live on the origin of the root unless `allow_foreign_hosts` says otherwise; recursion depth, document count and document size (before and after gunzip) are capped; external entities and network access are disabled in the XML parser. A failing nested sitemap is logged and skipped; a failing root throws `Http\Exception\TransportException`, and a response shorter than its `Content-Length` is a truncated download, never a document. Details in [SECURITY.md](https://github.com/indexnowkit/php/blob/main/packages/sitemap/SECURITY.md). A `` may name any host, so the command keeps only the entries whose host this site has a key for (`base_url` plus the `hosts` map — `KeyProviderInterface::managedHosts()`); the rest are dropped before anything fetches or submits them, and one line says how many and on which hosts. This matters most with [`indexnowkit/verify`](../verify/index.md): its pre-flight GETs every URL of the batch, so without the filter a sitemap **built from user content** (or a swapped one) could walk internal addresses. **When the sitemap is built from user content, set `strict_hosts: true`** as well, so a host that slips through is refused a key rather than sent under the default one. With no key at all (neither `base_url` nor a `hosts` map) there is nothing to compare against: the entries go as they are, with a warning. ## The command `Sitemap\Console\SitemapRunner` is the body of `sitemap [url]` (`--changed-since "1 day"`, `--allow-foreign-hosts`, `--force`, `--dry-run`, `--json`, `--no-verify`, `--new-only`); it streams, submits every `batch.max_urls` URLs, and submits the pending batch before reporting a mid-run failure (the re-run is idempotent, what was read is still worth announcing). `--new-only` submits only the URLs that are new or changed since the last run: the runner keeps the fingerprint of every entry it announced (URL plus ``, or none) in a `Sitemap\SeenStoreInterface` the application provides — the [`indexnow` CLI](../cli/index.md) keeps one in its state file, so a sitemap without `lastmod` still gets each change announced once; a framework adapter has no such store yet and answers `--new-only needs a store of seen URLs` with exit 2. Every run that is not `--dry-run` teaches the store (a failed batch excepted), so "the whole sitemap once, then `--new-only` in cron" announces each change exactly once. `--no-verify` submits past the pre-flight of [`indexnowkit/verify`](../verify/index.md) when the adapter has it enabled (a sitemap is the site's own list of its URLs, and a batch above `verify.max_batch` would go unverified anyway, with a warning); without the package the flag is accepted and changes nothing. `--force` ignores the debounce window (URLs announced within the last `debounce.per_url` seconds are sent again): for a deliberate one-off re-run, never in a schedule. `batch.max_urls` (10 000) is the protocol's ceiling, not a target: smaller batches are accepted just as well, and a scheduled run with `--changed-since` normally sends a few. The `check` command of every adapter carries `Sitemap\Check\SitemapSpoolCheck`: where documents are spooled, and whether that directory is writable — the kind of thing that otherwise only shows up on the first scheduled run. An application decorates the source (filter, rewrite) or replaces it (another format, a database) by implementing `Sitemap\SitemapSourceInterface` and binding it under the adapter's alias. Writing an adapter? [docs/adapters.md](adapters.md). ## For adapter authors `Sitemap\Adapter\SitemapServices` is what a framework adapter wires for this package, in one place: the predicate (`package()`), the owned options, the validated block, the reader, the spool check, the body of the `sitemap` command (`runner()`) and the command itself (`command()`: `Sitemap\Console\SitemapCommand`, the class an adapter on symfony/console registers instead of writing one) — as static functions over the pieces, with `*For()` twins over the core's `Adapter\Services` for a runtime graph. The Symfony bundle, the Laravel, the Yii2 and the Yii3 adapters build on it; see [adapters.md](../core/adapters.md) of the core. ## Requirements PHP 8.2+, `ext-xmlreader`, `indexnowkit/core ^0.13`, `indexnowkit/console ^0.5` (the command and its body render through `symfony/console`); `ext-zlib` for gzip-compressed sitemaps. ## Notes for AI assistants - Composer package `indexnowkit/sitemap` (add-on of `indexnowkit/core`; `ext-xmlreader`, `ext-zlib` for gzip). With a framework adapter it is the `sitemap` command — nothing to wire. - Minimal complete snippet (every `use` included): ```php use IndexNowKit\Config; use IndexNowKit\IndexNowKit; use IndexNowKit\Sitemap\SitemapConfig; use IndexNowKit\Sitemap\SitemapReader; $indexNow = IndexNowKit::create(Config::fromEnv()); $reader = SitemapReader::fromConfig(SitemapConfig::fromArray(['spool' => 'auto']), $indexNow->transport); foreach ($reader->read('https://www.example.com/sitemap.xml') as $entry) { $indexNow->collect([$entry->url]); } $indexNow->flush(); // batches of batch.max_urls, debounced ``` - Verify: the adapter's `check` command prints the `sitemap:` spool line; `bin/console indexnow:sitemap --dry-run`, `php artisan indexnow:sitemap --dry-run`, `php yii indexnow/sitemap --dry-run`. - Pitfalls: - `dispatch: auto` exists in Symfony (`auto` | `messenger` | `sync` | `none`) and Yii2 (`auto` | `queue` | `sync` | `none`), **not** in Laravel (`queue` | `sync` | `none`); Yii3 has `sync` | `none` only. - Locales: `router.locales` in Laravel, Yii2 and Yii3, `framework.enabled_locales` in Symfony; `locales: 'all'` on a rule uses that list. - `url:` names an accessor (method or property) that returns the URL; `urls:` is a list of literal URLs. Never put a literal in `url:`. - A string in `when:` is an accessor read as truthy (`published`, `isPublished`). A status string needs `Equals`: `when: new Equals('status', 'published')` (`IndexNowKit\Attribute\Param\Equals`). - Manual submission is `submitEntity()` in Symfony, `submitModel()` in Laravel, `submitRecord()` in Yii2 and Yii3; the commands are `indexnow:submit-entity`, `indexnow:submit-model`, `indexnow/submit-record` (Yii2), `indexnow:submit-record` (Yii3). Bulk queries (`update()`, `DB::table()`, `updateAll()`) fire no hooks: submit afterwards with those. - Laravel has two classes called `IndexNowKit`: the facade `IndexNowKit\Laravel\Facades\IndexNowKit` and the core service `IndexNowKit\IndexNowKit` (inject by type). Yii2 exposes the core through `Yii::$app->indexnow->kit()`; Yii3 defines `IndexNowKit\IndexNowKit` in the container. - Outside production a configured key with `dry_run` unset makes `check` fail (a staging copy would submit real URLs): set `dry_run: true` there, or `dry_run: false` explicitly when it submits on purpose. - Unknown configuration keys are warned about at boot (typos such as debounce.per_urls); the key list is `Config::OPTIONS` plus the adapter's own keys. ## Versioning SemVer; until 1.0 minor versions may contain breaking changes, listed in [CHANGELOG.md](https://github.com/indexnowkit/php/blob/main/packages/sitemap/CHANGELOG.md). What the compatibility promise covers: [docs/bc.md](bc.md). MIT. IndexNow is a trademark of its owner; this project is independent and not affiliated with Microsoft, Yandex or indexnow.org. # Wiring the `sitemap` command into an adapter The command body, the reader and the check live here; an adapter parses its own input and binds three objects. Everything reads one `SitemapConfig`, built from the raw `sitemap` block of the adapter's configuration. ```php use IndexNowKit\Sitemap\Check\SitemapSpoolCheck; use IndexNowKit\Sitemap\Console\SitemapOptions; use IndexNowKit\Sitemap\Console\SitemapRunner; use IndexNowKit\Sitemap\SitemapConfig; use IndexNowKit\Sitemap\SitemapReader; $sitemap = SitemapConfig::fromArray($raw['sitemap'] ?? []); // throws ConfigurationException naming the key $reader = SitemapReader::fromConfig($sitemap, $kit->transport ?? TransportFactory::lazy($kit->config), $logger); $check = new SitemapSpoolCheck($sitemap); // add it to the checks of your `check` command $runner = new SitemapRunner($kit, $reader, $submitterFactory, $sitemap->url, $formatter, sitemapUrlOption: 'myfw.sitemap.url'); $exit = $runner->run($io, new SitemapOptions($argument, $changedSince, $allowForeignHosts, $force, $dryRun, $json)); ``` - **Configuration.** Add `SitemapConfig::OPTIONS` to the keys your `Adapter\ConfigFactory` owns (`ownedOptions: [...MY_OPTIONS, ...SitemapConfig::OPTIONS]`), so a typo inside the block is warned about. Do not list a bare `sitemap` key: it would stop `Config::unknownOptions()` from looking inside the block. At runtime build the block with `SitemapConfig::loadOrDisabled($block, $logger, 'php artisan indexnow:check')`: an invalid block is one `critical` line and `SitemapConfig::disabled()`, the way the core `ConfigFactory::load()` treats the core options (a DI container that validates at compile time, like the bundle, uses `fromArray()` and lets the build fail); when `enabled` is false, register no command (bundle) or refuse to run it with `sitemap.enabled is false.` and exit `INVALID` (Laravel, Yii2). - **Log lines.** The one line the wiring above adds, so operators can grep for it (the core's `docs/operations.md` lists the rest): | Level | Message | |---|---| | `critical` | `indexnow: invalid sitemap configuration, the sitemap command is disabled until it is fixed: {error} (run "{check}")` — `loadOrDisabled()`, `{check}` is the adapter's check command (Laravel, Yii2) | - **Transport.** The reader fetches over the transport the facade submits through, so `http.client` and `http.timeout` apply and nothing is discovered twice. `$kit->transport` is `null` only when the facade was built around a custom submitter; `Http\TransportFactory::lazy($kit->config)` covers that. - **Source.** Type the command against `SitemapSourceInterface` and expose the reader under an alias of it, so an application can decorate the source (filter, rewrite) or replace it. `--allow-foreign-hosts` only reaches the shipped `SitemapReader`; the runner warns when the configured source is something else. Whatever the source, the runner keeps only the entries on hosts `$kit->keys->managedHosts()` names, and counts the rest in one line: a replacement source does not have to filter hosts itself, and cannot opt out of it. - **Output.** The runner streams, submits every `batch.max_urls` URLs through `Adapter\SubmitterFactory::choose()` (`--force`/`--dry-run` get a separate submitter), folds results into `Submission\ResultSummary`, and submits the pending batch before reporting a mid-run failure; `--json` keeps stdout machine-readable (the error goes to stderr). Exit codes are `Console\ExitCode` of `indexnowkit/console`. - **Words.** The only framework-specific string is `sitemapUrlOption`, printed in `Give a sitemap URL, or configure