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 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:
$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).
final class IndexNowIntegration
{
public readonly Services $services;
private readonly ObserverHelper $hooks;
/** @param array<string, mixed> $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 suggested, 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()isclass_exists()of the marker unless the adapter passes an override (null= detect; the bundle'ssitemapInstalledconstructor argument, a Laravel container binding underIndexNowKitServiceProvider::SITEMAP_PACKAGE, the Yii2 component'ssitemapInstalledproperty, the Yii3 service'ssitemapInstalledargument). No statics: the override travels with the adapter's own configuration.notInstalledMessage(),checkLine(),checkLevel()andcheck()are the three texts below, written once. Ask the core, not the package:Sitemap\Adapter\SitemapServices::package()returns the same object but lives inindexnowkit/sitemap— an adapter that called it to find out whether the package is installed booted fine in its test suite (the package inrequire-dev,installed: falsepassed by hand) and was aClass not foundin an application without the package. The CI joboptional-packages-absentremoves 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 (<Adapter>\Console\SitemapCommand, and whatever registers the reader, the spool check and the runner). A::classconstant on an absent class is safe;SitemapConfig::OPTIONS,SitemapReader::MAX_*orSitemap\Console\Definitionsin a file that is loaded without the package are a fatal. - The package wires itself:
Sitemap\Adapter\SitemapServices,Verify\Adapter\VerifyServicesandHistory\Adapter\HistoryServiceshold 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 overAdapter\Servicesfor 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 orhttp.clientis looked up, the queue facts ofstatus, 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, printsindexnowkit/sitemap is not installed: composer require indexnowkit/sitemapand exitsExitCode::FAILURE: a cron that ransitemapbefore the package went optional gets a sentence, not "command not found". OptionalPackage::check($block, $defaults)in the checker (aCheck\StaticCheck):sitemap: not installed (composer require indexnowkit/sitemap)at level ok when the block is absent or equal to the defaults the adapter ships, orsitemap: 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\SampleGateCheckoverCheck\SampleOptions, both in the core, forcheck --sample/--sample-class.verifyis the one optional package with a command option in front of it, so the check that decides what--samplemeans 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.SampleOptionsis one mutable holder per graph that thecheckcommand fills with what it parsed (urls,classes, and thesamplerclosure 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'sVerify\Check\SampleCheck;::withoutPackage($options, $package, $block, $defaults)writes the "not installed" line of the predicate with— pre-flight checks off, or an errorcheck --sample needs indexnowkit/verifywhen a sample was given anyway. Every line it writes carries the codeverify.installed(SampleGateCheck::CODE), neververify.sample— that code exists only while the package does (check-codes.md).SitemapConfig::loadOrDisabled($block, $logger, $checkCommand)(fromindexnowkit/sitemap) builds the sitemap configuration at runtime: an invalid block is onecriticalline naming the error and your check command, and a disabled configuration — nothing throws from the container.ConfigFactory(ignoreBlocks: ['sitemap'])without the package (and...SitemapConfig::OPTIONSinownedOptionswith it), so a configuration written for the package does not warn as "unknown option" once the package is gone.ownedOptionsstays dotted: a baresitemapin 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:
$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.
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:
$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-<x> (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:
$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:
public function locales(array|string $locales): array; // list<string|null>; [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$hostis given, prefer$config->baseUrlFor($host)and fall back tohttps://$host.- Wrap your router's exceptions in
ConfigurationExceptionwith 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.
$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<ResolvedUrl>
$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
RuleEvents first and resolve them later:
$events = $changes->updatedEvents($model, array_keys($changeSet), $changeSet); // list<RuleEvent>
// ... 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
whenjust turned false:updatedEvents()returns it asEvent::Deleted, and it must be resolved before the write, not after. - Supply both
$changedFieldsand$changeSetwhen you have them. The change set (field => [old, new]) is what makes the old-state visibility exact instead of heuristic. See attribute-reference.md. - 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¶
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.
$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):
$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. HandingrowMatches()every non-null property of a new record is how a single DECIMAL ortimestamptzcolumn silences the announcement of every new page of a class. A delete asks fornullthe 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 as19.9, a zone suffix on atimestamptz, 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$expectedyourself 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. 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:
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:
$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:
$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:
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 <url>... |
Console\SubmitRunner |
— |
submit-<subject> <class> [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 <class> <id> |
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). 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. 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): 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/<framework>, 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.
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.
20. Definition of Done for an adapter¶
- [ ]
Adapter\ConfigFactorydeclared with dottedownedOptions(plusSitemapConfig::OPTIONSwhen 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 onecriticalline and never throws from a hook. - [ ] The graph is built through the factories (
Http\TransportFactory,Debounce\DebounceStoreFactory,Dispatch\DispatcherFactory,fromConfig()); no copiedmatchoverdebounce.store, no own "not a PSR-18 client" text, no own class-name resolution (Console\ClassNameResolver). - [ ]
#[IndexNow(resolver: ...)]throughArrayResolverLocator(locate:, hint:); the resolver isGuardedUrlResolver. - [ ] Hooks over
Hook\ObserverHelper(guard, deliver, remembered deletions; no ownWeakMap, 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 overAdapter\ServicesBuilderwithqueueFactory(). - [ ] Flush at the end of every unit of work (request, command, queue message);
Collector::reset()in long-running runtimes. - [ ]
Key\KeyFileRequestHandleron a PSR-15 stack, elseKeyFileResponder::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) andindexnowkit/history(History\Console\HistoryCommand,StatusCommand), registered with their runners, aVocabulary, aConfigSourceInterfaceand aSampleOptions; on another console layer six commands over the runners ofindexnowkit/console(check,config,submit,submit-<subject>,explain,key:generate),sitemapfromindexnowkit/sitemap,historyandstatusfromindexnowkit/history; their inputs fromConsole\Definitions/Sitemap\Console\Definitions/History\Console\Definitions(no own option descriptions),checkwith yourCheckInterfacelines plusCheck\DebounceStoreCheck(with a probe),Check\SampleGateCheckandSitemap\Check\SitemapSpoolCheck. - [ ]
indexnowkit/sitemapinsuggestandrequire-dev, behind one predicate (§2 "Optional packages"): without it thesitemapcommand is a stub that explains what to install and exits 1,checkprints theStaticCheckline, asitemapblock 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/testinginrequire-dev; conformance kits green (C01–C22, A01–A21 for an ORM, H01–H06 throughTesting\Conformance\KeyFileAssertionsandCheckOutputAssertions); 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.