Repository navigation
Releases
0.13.1
Added
Config::arrayFromEnv(?array $env = null, string $prefix = 'INDEXNOW_'): array(wave N, spec 19b §3.3): the
INDEXNOW_*variablesfromEnv()reads, as the nested arrayfromArray()takes and holding only the variables
that are set (values as strings,fromArray()coerces them). What an application without a framework merges over
its configuration file with the environment on top —toArray()carries every default and cannot serve there.
fromEnv()is nowfromArray(arrayFromEnv());Config::fromArray(Config::arrayFromEnv($env))equals
Config::fromEnv($env), and a test keeps it so. TheindexnowCLI (indexnowkit/cli) reads its configuration this
way. Call tier. Released as a patch (0.13.1) although it is an addition: the family's cascade rule (spec 19b §4)
— a^0.14would force a release of every package that requirescore ^0.13, for one method none of them calls.
0.13.0
Additive: the predicates of the three optional packages move into the core, so that an adapter can ask "is
indexnowkit/sitemap installed?" without loading a class that lives in indexnowkit/sitemap.
Added
Adapter\OptionalPackage::sitemap(),verify(),history()(each?bool $installed = null): the Composer names,
the marker classes (Sitemap\SitemapReader,Verify\PageSignals,History\HistoryConfig, written as strings — the
core names no class of a package it does not require) and the feature words of the family's optional packages, in
the core.Sitemap\Adapter\SitemapServices::package(),Verify\Adapter\VerifyServices::package()and
History\Adapter\HistoryServices::package()delegate to them (sitemap 0.7.1, verify 0.3.1, history 0.3.1); an
adapter calls the core's method directly. Before, the Symfony bundle, the Laravel and the Yii2 adapters called the
packages'*Services::package()unconditionally, which is aClass "IndexNowKit\Sitemap\Adapter\SitemapServices" not foundat the first configuration build of an application without the package (symfony-bundle 0.14.1, laravel 0.14.1,
yii2 0.13.1 fix it; the tests of wave I passedinstalled: falsewith the packages still installed, so they did not
catch it).docs/adapters.md§2 "Optional packages" names the new recipe;docs/bc.mdlistsAdapter\OptionalPackage
in the "call" tier, where it always belonged.
Then the six-lens audit of 0.13 (docs/plans/audit-0.13.md in the specification workspace), run on this wave before it
was tagged. Everything below went into the same unreleased minor, so there is no 0.13.0 in the wild without it. Two of
the findings lost URLs silently: an insert whose verifier compared a DECIMAL column never announced the new page, and a
dry_run given as the string "false" read as true. The rest is the shape of the API the next adapter is written
against, plus the four constructions every adapter had copied.
Added
- What every router bridge, queue dispatcher and locale check of the family had copied, as core classes (wave M,
spec 19).Url\RouteOrigin(static, "call" tier):expand()— the locales a rule generates for,'all'over the
adapter's list, with one warning per process when that list is empty (the rule silently collapsing to one URL was
logged by the Laravel bridge only; the other three now say it too);pinnedRoot()—hosts.<host>.base_url, else
https://<host>;rebase()— scheme, host and port of an origin over the path, query and fragment of a generated
URL;generationFailed()andnoRequestHost()— the twoConfigurationExceptions, one text each (four bridges had
four spellings ofCannot generate route).Dispatch\BatchingDispatcher(Closure $enqueue, Config, ?Logger, string $noun = 'job'): one unit perbatch.max_urls,newJobId()(twelve hex characters: three adapters had
bin2hex(random_bytes(6))each), a push that throws logged ascannot queue {count} URL(s) ({noun} {id}), they are lostwithConfig::logSample()and never rethrown; the adapters' dispatchers are the push closure around it.
Check\LocalesCheck($locales, $rules, Closure $classes, $option = 'router.locales', ?string $parameter = null), code
router.locales: the one check three adapters printed with three texts — the warning names the option and up to
three classes ("and N more"), the ok line names the locales and the route parameter when the adapter gives one, a
class whose rules cannot be read is skipped.Check\DispatchLine::describe($dispatch): thesync/none
sentence of the adapters' queue checks. Debounce\DebounceStoreFactory::isShared(?string $store): whetherdebounce.storenames a cache shared by
every process, the question nine places across five packages answered with their ownin_array($store, [MEMORY, NONE], true)(the bundle with the literals).Check\DebounceStoreCheck::PROBE_KEY(indexnowkit_check): the
key every adapter's probe writes — two adapters wroteindexnowkit:check, and:is a character PSR-16 reserves,
so a strict cache (yiisoft/cache) refused the probe andcheckreported a working store as broken. The docblock of
the check says "writes a test key" now; two probes only read one.Adapter\OptionalPackageknows the options its package owns: a fifth constructor argument,?string $optionsProvider— the name of the package's*Services::options()as a string, so the core still names no class
of a package it does not require — filled bysitemap(),verify(),history();options()calls it only when
installed()says yes; the staticownedOptions(list<OptionalPackage>)andignoredBlocks(list<OptionalPackage>)
fold a list of predicates into the two arguments ofAdapter\ConfigFactory, which three adapters built from eight
identical lines each. A fourth package is one line in the core now, not three edits in three adapters.Http\Psr18Transport::discover()andHttp\TransportFactory::lazy()take the application's PSR-17 factories
(?RequestFactoryInterface $requestFactory,?StreamFactoryInterface $streamFactory, appended and optional): a
PSR-7 stack with aPsr17Factoryservice of its own (Slim, Mezzio, Yii3) hands it over instead of having
php-http/discoveryfind one;nulldiscovers, as before. The docblock ofsendRequest()says why the two PSR-18
exception subtypes are not told apart;docs/adapters.md§12 says what the transport does about redirects (nothing
the standard does not: off on the clients it builds, the application's own client keeps its defaults) and §13 why the
core takes PSR-16 only (wrap a PSR-6 pool in the view your ecosystem ships) and §11 which key-file recipe each stack
gets.Testing\ArrayLogger's docblock says it does not check the level — a double, not a PSR-3 logger.Key\KeyFileRequestHandler(wave L, spec 18 §3.5):GET /<key>.txtover PSR-7 for any PSR-15 stack — a
RequestHandlerInterface(the action of a route: the key is read off the path), aMiddlewareInterface(in front
of the router: a request it does not serve goes on to the next handler untouched) andrespond(?string $key, $request)
for a key the router already extracted. The body isKeyFileResponder's, the headers areConfig::keyFileHeaders(),
the host is the request URI's;fromConfig($config, $keys, $responseFactory, $streamFactory)builds it over the
adapter's key provider and PSR-17 factories. Nothing changes in the semantics — only the PSR-7 response is built in
one place instead of in every adapter (the Yii3 handler delegates to it).psr/http-server-handlerand
psr/http-server-middlewarejoinrequire(interfaces only; the core stays PSR-only).Adapter\SubmitterFactorytakes?ClockInterface $clockas its last parameter and passes it to theSubmitter
it builds;Adapter\Services::submitterFactory()gives it the graph'sclocknode (A1). Appended and optional, so
the "call" tier holds. Without it theclocknode stopped at the application's submitter:Testing\FrozenClockleft
submit,submit-recordandsitemapon the wall clock, the submission history recorded commands and requests on
two different clocks, and the debounce window was counted twice over.Adapter\Services::requireRouter()andrequireResolverLocator()(A9): the same nodes asrouter()and
resolverLocator(), but typed non-nullable and throwing aConfigurationExceptionthat names the missing node. An
adapter that always wires one wrote?? throwwith a message nobody ever read; two adapters had shipped that dead
branch before this existed.- The
eventsclosure ofAdapter\ServicesBuildermay returnnull(A8), like the nullable nodes. An adapter
whose container may or may not hold aPsr\EventDispatcher\EventDispatcherInterfacenow answers that inside the
closure instead of asking the container duringbuild(), which is documented to do no IO. Check\SampleGateCheckoverCheck\SampleOptions(A2): the--sample/--sample-classgate in front of the
optionalindexnowkit/verify, in the core because it has to load without the package. All four adapters carried a
byte-identical copy of the class, the holder and the two user-facing sentences. Every line it writes carries the code
verify.installed, which is what the adapters always printed —docs/check-codes.mdhad documentedverify.sample
for it, a code that exists only while the package does (D1).Hook\ObserverHelper::forKit()next toforChanges()(A23): the plain case as a named constructor, so the
union-typed constructor with its conditionally required$sinkis no longer the way in.Transaction\VerifyingStaging::stage()takes a subject key (?string $key, appended) and merges a later change
of the same subject into the entry already staged, keeping both sets of URLs and the newer verifier (R4). Without it
two writes to one row inside one transaction staged two entries, both verified at the end against the last state
of the row, so the first one's URLs were dropped as "did not land" — including the old URL of a two-step rename.Url\ArrayResolverLocatorwraps a throwing container lookup in aConfigurationExceptionnaming the id
(IndexNow URL resolver "%s" cannot be built by the container: %s) (A10). Three adapters had copied that try/catch
with three different sentences and the fourth had none at all, so a container error escaped it raw, past
IndexNowException. Adapters handlocate:a bare lookup now.
Fixed
- PHP 8.5 without deprecations, phpstan on PHP 8.4+ clean —
Url\ViaWalkuses theSplObjectStorageoffset methods
(attach()/contains()are deprecated since 8.5),Url\UrlNormalizerdecodes a percent-escape withhex2bin()and
Attribute\RuleCompiler::compile()is@template T of objectover its `ReflectionCla...
0.12.0
The design decisions of the 0.10 audit (docs/plans/audit-0.10.md §6 in the specification workspace). Three change
signatures; this is the one breaking minor that carries them, so that the next adapter is written against the final ones.
Changed
Attribute\Param\FieldConditionno longer extendsCondition, andEquals::evaluate()is gone. A field condition
is read by the graph'sParamExtractor(heldFor()of the value it reads forfield()), so it never reads the object
itself;evaluate()built a plainParamExtractorinside and answered wrong on Eloquent attributes.whenaccepts
string|Condition|FieldCondition|Closure. To evaluate one by hand:$extractor->condition($subject, $equals). A custom
FieldConditiondrops itsevaluate()(an extra method is harmless); a customConditionis unchanged.- The extractor is a required parameter:
Url\AttributeUrlResolver::__construct($reader, $extractor, ...)and
::fromConfig($config, $reader, $extractor, ...),Url\ObjectChangeHandler::__construct($rules, $resolver, $extractor, $logger),Attribute\ChangeClassifier::classify($rule, $subject, $extractor, $changedFields, $changeSet),
Attribute\UrlRule::appliesTo($subject, $extractor). Each fell back tonew ParamExtractor()when the argument was
omitted, and the only symptom of an adapter forgetting it was URLs lost on Eloquent/ActiveRecord objects; now static
analysis catches it.ParamExtractor::plain()names the DSL-only extractor for graphs built by hand (plain PHP,
Doctrine).IndexNowKit::create(extractor:)and the facade constructor stay optional: the facade derives it from the
resolver (Url\ParamExtractorAwareInterface). IndexNowKit::create()and the constructor withoutresolver:buildAttributeUrlResolver::fromConfig(), as
Adapter\ServicesBuilderdoes, instead ofNullUrlResolver: a class with#[IndexNow]handed to the bare facade resolves
itsurl/urlsrules (aroute:rule without a router is an error the guard logs), where before it silently yielded
nothing.resolver: new NullUrlResolver()is the explicit way to resolve nothing.
Added
IndexNowKit::submitEntities(), the bulk twin ofsubmitEntity()(thesubmitX/submitXsscheme of every adapter:
submitModels(),submitRecords()).submitAll()is its@deprecatedalias, removed in the next minor
(ClientInterface::submitAll(), the URL-level method, is unrelated and stays).Attribute\ParamExtractor::plain().
Documentation
docs/bc.md:ObjectChangeHandler::renamed($subject, $changeSet, $previous, $selfFields)—$previousis the contract
for adapters whose objects cannot be reset by reflection (Eloquent);Attribute\SubjectReaderInterfacestays read-only,
nowrite()is planned.docs/attribute-reference.md: conditions rewritten for the split.
0.11.0
Wave G: the fixes of the six-lens audit of 0.10 (docs/plans/audit-0.10.md in the specification workspace) that needed no
design decision. Nothing is removed; a few behaviours change where the old one lost or duplicated URLs.
Changed
Url\AttributeUrlResolverviawalk: the total number of related objects resolved in one walk is capped at
resolver.max_via_depth × resolver.max_via_fanout(a warning, then the walk stops); an object already visited in the
walk is not entered again (a cycle through different accessor names,Post::$tags -> Tag::$posts, or a second path
to the same object); exceeding the depth is a warning and keeps the URLs of the shallower levels instead of a
ConfigurationExceptionthat dropped everything the rule had collected.IndexNowKitderives its extractor from the resolver (Url\ParamExtractorAwareInterface, implemented by
AttributeUrlResolver;GuardedUrlResolver::inner()): a custom resolver built with readers made the change handler
andexplainreadwhenwith the plain DSL and fail silently. The constructor parameter is?ParamExtractornow,
null= the resolver's;$extractorstays a public readonly property. A new optional?ObjectChangeHandler $changes
lets a graph pass its own handler (Adapter\Services::changes()).Check\Checker::robotsDisallows()follows RFC 9309 group selection: a crawler obeys the group that names it, else
*; the two are no longer merged, soUser-agent: * / Disallow: /private/plusUser-agent: bingbot / Allow: /no
longer blocks the verify pre-flight for Bing. Each IndexNow engine is evaluated with its own group.Throttle\TokenBucketcredits exactly the deficit after a wait instead of refilling over the slept time again:
with a real clock four requests at 2/min took 30 s, not 60.fromConfig()takes an optionalClockInterface.Retry\ForbiddenCounter: the fresh-counter TTL write re-reads the counter first (a hit that landed between the
two calls is kept);reset()reads the shared cache once per streak instead of once per successful batch; without a
cache the escalation happens once per streak at or above the threshold, as with one.Transaction\TransactionStaging::commit()catches a throwing sink and logs it: the database has committed, so an
exception out ofcommit()told the application its commit failed.Url\ObjectChangeHandlerputs every field back when restoring a previous state even if onesetValue()throws
(a PHP 8.4 set hook), then rethrows.Retry\WorkerOutcomecounts a retryable skipped result (the verify pre-flight on an origin error) as retryable,
not as a final failure.Http\Response::parseRetryAfter()understands severalRetry-Afterheaders joined by the header line
(120, 60: the longest wins) instead of returning null.Attribute\ParamExtractor::read()treats a declared but uninitialized typed property as "no property" (a
ConfigurationExceptionwith the usual hint) instead of a rawError.Clientmasks the previous key of the host in log excerpts too, not only the current one.Url\CanonicalUrlNormalizerdecodes+as a space when matching tracking parameter names.Testing\FakeTransporthas a$beforeGethook (a test that advances a clock per GET).
Added
Adapter\Services::changes()/ServicesBuilder::changes(): the change handler is a node of the graph, built over
the rules, the guarded resolver and the extractor only — an ORM hook that yields no URL builds neither the client nor
the store (before,changes()went throughkit(), which built everything).Adapter\Services::clock()/
ServicesBuilder::clock()andIndexNowKit::create(clock:): one clock for the throttle, the debounce store and the
submission timestamps (Testing\FrozenClockin an adapter's tests).ServicesBuilder::events()accepts a
Closure(called on first use). A closure node may returnnullfor the nullable nodes (failureCache,
submissionStore,router,resolverLocator).Services::events()returns the dispatcher.Hook\ObserverHelper::forChanges(ObjectChangeHandler $changes, Closure $sink)(and the constructor accepts an
ObjectChangeHandlerwith a$sink): the helper over a change handler alone, for a graph that builds lazily.Http\Psr18Transport::discover(..., ?int $getBodyLimit)andHttp\TransportFactory::lazy(..., ?int $getBodyLimit):
a GET body limit per transport (the verify pre-flight uses 1 MiB, not the 50 MiB of key files and sitemaps).docs/bc.md:RouteUrlResolverInterfaceandResolverLocatorInterfaceare "Implement" (the core ships nothing to
decorate);Attribute\Param\ParamValueis a sealed set;symfony/polyfill-intl-idnis suggested for hosts the
built-in punycode fallback maps incompletely without ext-intl.
0.10.0
The last structural debt before 1.0 (spec 17 §7): the reader registry of Attribute\ParamExtractor was process-wide static
state — two graphs in one process shared it, a test had to unregister what another registered, and nothing in the type
system said which extractor a resolver read with. It is an object now, built once per graph and injected.
Changed
Attribute\ParamExtractoris an instance (tier Call; constructors take named arguments):new ParamExtractor(...$readers)
holds theSubjectReaderInterfaces of the graph;extract(),read(),resolve()andcondition()are instance methods
with unchanged signatures;with(...$readers)returns a copy with more readers,fromReaders(iterable)builds one for a
container,readers()lists them. Removed: the staticregisterReader()/unregisterReader(). Migration: plain PHP
—IndexNowKit::create($config, extractor: new ParamExtractor(new MyReader())); a call site that used the static methods —
(new ParamExtractor())->read(...)(the DSL alone) or the graph's instance ($indexNow->extractor). The adapters bind
theirs (ParamExtractor::classin Laravel,indexnowkit.param_extractorin Symfony,ServicesBuilder::paramExtractor()
in Yii2) and theirEloquentSubjectReader/ActiveRecordSubjectReadergo in there.- A
FieldConditionis evaluated through the extractor:ParamExtractor::condition()readsfield()with its readers
and asksheldFor(), sonew Equals('status', 'published')sees an Eloquent attribute the same wayparamsdoes; a plain
Conditionstill runsevaluate($subject).Equals::evaluate()alone reads with the plain DSL (it was the static
registry before); the core never calls it for aFieldCondition. The "no property, getter or method" error says "give the
ParamExtractor a SubjectReaderInterface" and lists the readers that were asked.
Added
- Appended optional parameters carrying the extractor:
IndexNowKit::create(..., ?ParamExtractor $extractor), the
constructor ofIndexNowKit(public readonly ParamExtractor $extractor),Url\AttributeUrlResolver(constructor and
fromConfig(), plusextractor()),Url\ObjectChangeHandler,Attribute\UrlRule::appliesTo(object, ?ParamExtractor),
Attribute\ChangeClassifier::classify(..., ?ParamExtractor). Null everywhere means the plain DSL, which is what plain PHP
objects and Doctrine entities need — nothing changes for them. Adapter\Services::paramExtractor()andAdapter\ServicesBuilder::paramExtractor(ParamExtractor|Closure): the
node the resolver, the change handler and the facade of the graph share.docs/compatibility.md: the supported PHP and framework versions per package, their upstream end-of-life dates and the rule
for dropping one (the minimum PHP is raised in the first minor after the previous version leaves security support; that
is not a breaking change underdocs/bc.md).
0.9.0
The additive minor of spec 17 §7: nothing changed, nothing removed, no @deprecated added — the interfaces of 0.8.0
(Submission\SubmissionStoreInterface, Attribute\Param\Condition, Http\Response::headers(), Check\CheckItem::code, the
failure cache of Client) live one minor unchanged. indexnowkit/verify and indexnowkit/history are built on it.
Added
Retry\ForbiddenCounter— the consecutive-403 counter ofClientas a class of its own (tier Call):
hit(host): array{int, bool}(the new count and whether this hit crosseslogging.forbidden_escalationonce per
streak),reset(host),count(host),isEscalated(host),threshold(),key(host, escalated). Over a PSR-16
cache the counter and the flag live under<debounce.key_prefix>403.<host>/…_escalatedwith a TTL (increment()
used when the store has it, a failing store is logged once and the process counts on), without one in the process.
Clientbuilds it from its unchanged constructor arguments ($failureCache,$failureCacheTtl); its behaviour and
tests did not change.Adapter\Services::forbiddenCounter()returns a reader over the same cache, prefix and
threshold — whatindexnow:statusofindexnowkit/historyprints per host.Http\TransportFactory::lazy(..., array $extraHeaders = [])andHttp\Psr18Transport::discover(..., array $extraHeaders = [])(appended): headers sent with every request of that transport.TransportInterface::get()
takes no headers, so this is howindexnowkit/verifysends its ownUser-Agenton the pre-flight GETs.
0.8.0
Added
- Stable codes on every
checkline (spec 17 §5.1).Check\CheckItemcarries?string $codeand?string $host
(appended, defaults null);CheckReport::ok()/warning()/error()take them as appended optional arguments, and
CheckReport::add(),hasWarnings()andstatus()(the worst level) are new. Every line ofCheck\Checker,
Check\DebounceStoreCheck(DebounceStoreCheck::CODE) andAdapter\OptionalPackage::check()(checkCode():
<feature>.installed) names its check;Check\StaticChecktakes the code as an appended constructor argument. The
codes are API and listed in docs/check-codes.md; the texts are not. Lines about one host
carry it in$host. Http\Responseexposes the response headers:array<string, string> $headers = []as the appended fourth
constructor argument (names lower-cased; several values joined with,),header(),contentType(),
cacheMaxAge()(s-maxage, elsemax-age;no-store/no-cacheare 0) andage().Http\Psr18Transport::get(),
post()anddownload()fill them;Testing\FakeTransport::download()passes them through. A custom transport
that leaves them empty keeps working:checkprints one neutral line instead of a verdict.- New
checklines (spec 17 §5.1):key_file.content_type(error when the key file is served as something other
thantext/plain),key_file.cache_control(warning whenCache-Control/Ageexceedkey_file.cache_max_age),
key_file.robots(warning whenrobots.txtdisallows the key file path for every bot or an engine's bot;
Checker::robotsDisallows()is public),key_file.previous(withprevious_key: the old key file still served is
the open rotation window, otherwise a warning) and the globalhttp.clientwarning about redirects through a
custom client.checknow fetches/robots.txt(and the previous key file) of every host after its key file. - The 403 escalation counts across processes (spec 17 §5.3).
Client::__construct(..., ?CacheInterface $failureCache = null, int $failureCacheTtl = Client::FAILURE_CACHE_TTL)(appended): with a PSR-16 cache the
consecutive-403 counter and the "escalated" flag of each host live under<debounce.key_prefix>403.<host>(and
…_escalated) with the TTL, so a fleet of PHP-FPM and queue workers writes thecriticalline once per streak; a
cache withincrement()counts atomically, any other approximately; a cache that throws is logged once and the
process counts on. A non-403 answer deletes the counter only when it is set.Adapter\SubmitterFactorytakes the
cache as an appended argument,Adapter\ServicesBuilder::failureCache()/Services::failureCache()carry it,
IndexNowKit::create(..., failureCache:)passes it.Check\Checkerprobes without it. Submission\SubmissionStoreInterface(spec 17 §5.4):record(Result, DateTimeImmutable),recent(limit, host, status),lastFor(url);Submission\SubmissionRecord(urls,result,at) andSubmission\NullSubmissionStore.
Submitter::__construct(..., ?SubmissionStoreInterface $store = null, ?ClockInterface $clock = null)(appended)
records one entry per Result after the listeners, skipped results included; a throwing store is one error log line
and never affects delivery. Wired throughAdapter\SubmitterFactory(appended$store),
Adapter\ServicesBuilder::submissionStore()/Services::submissionStore()andIndexNowKit::create(..., submissionStore:). Implement tier; the "state → records" table is docs/submission-store.md.
Theindexnowkit/historypackage will ship the stores.- Canonical URLs (spec 17 §5.5):
Url\CanonicalUrlNormalizerdecorates a normalizer and strips tracking
parameters (CanonicalUrlNormalizer::TRACKING_PARAMS, a growing constant:utm_*,gclid,dclid,wbraid,
gbraid,fbclid,msclkid,yclid,ysclid,_openstat,etext,ttclid,twclid,igshid,mc_cid,
mc_eid,mkt_tok,_hsenc,_hsmi,_ga), applies a trailing-slash policy and optionally sorts the query,
before de-duplication and debounce. Optionsnormalizer.strip_tracking_params(default true),
normalizer.tracking_params,normalizer.trailing_slash(keep|add|strip),normalizer.sort_query
(Config::OPTIONS,Config::TRAILING_SLASH_MODES).Url\UrlNormalizerFactory::fromConfig(Config)builds the
normalizer everywhere the core usednew UrlNormalizer(...)(Submitter,Client,IndexNowKit::create(),
Adapter\Services,Check\Checker), so the options work in plain PHP too. Attribute\Param\ConditionandFieldCondition(spec 17 §5.6):whentakes a string accessor, aCondition
(evaluate(object): bool—Equals, or your own class) or a closure. AFieldCondition(field(),
heldFor($oldValue)) letsChangeClassifierread the old state from the ORM change set; a plainConditionis
evaluated on the current object and cannot see an unpublish unlesswhenFieldsnames the field (documented in
attribute-reference.md).ParamExtractor::condition()takesstring|Condition|Closure;UrlRule::accessorOf()
reads aFieldCondition's field.Config::toArray(): the effective configuration in the nested shapefromArray()takes, every option present with
its resolved value (Config::fromArray($config->toArray())is an equal configuration); keys are not masked, the
configcommand does that.Reasoncases for theverifypackage (spec 17 §5.7; the enum is closed, so the core reserves them):Noindex,
RobotsDisallowed,NonCanonical,Redirected(skips, not retryable) andOriginError(skip, retryable), with
messages and translation keys;Reason::isRetryable()is new. The table of every case is in docs/bc.md. Amatch
overReasonwithout adefaultarm fatals on them, as documented.
Changed
- Tracking parameters are stripped by default (
normalizer.strip_tracking_params: true): a URL withutm_*,
gclid,fbclid, … is submitted and debounced as its clean form. The debounce keys of such URLs change once;
URLs without those parameters are unaffected. Setnormalizer.strip_tracking_params: falseto keep the old
behaviour. Attribute\Param\Equalsis aFieldCondition, not aParamValue.params: ['x' => new Equals(...)]— which
never produced a meaningful value — is now aConfigurationExceptionfromParamExtractornaming the fix (Equals
belongs towhen).whenis typedstring|Condition|Closure|nullinIndexNow,IndexNowDefaults,
IndexNowUrl,UrlRule,RuleCompiler,ParamExtractor::condition()andChangeClassifier: aParamValue
(Accessor,Value,Formatted,Call) inwhenis aTypeError. Migration:when: new Accessor('x')→
when: 'x'; anything else inwhenbecomes aCondition.
0.7.0
Changed
Console\SubmitterFactoryandConsole\SubmitterFactoryInterfaceareAdapter\SubmitterFactoryand
Adapter\SubmitterFactoryInterface;Console\ResultSummaryisSubmission\ResultSummary(spec 17 §4.2: they are
called fromAdapter\Servicesand the adapters, not only from the commands, so they stay in the core when the
commands move toindexnowkit/console). Migration: change theuselines —
use IndexNowKit\Adapter\SubmitterFactory;,use IndexNowKit\Adapter\SubmitterFactoryInterface;,
use IndexNowKit\Submission\ResultSummary;.Adapter\Services::submitterFactory()returns
Adapter\SubmitterFactoryInterface. Nothing else changes: same constructors, same methods.Testing\Conformance\*and the assertion helpers are theindexnowkit/testingpackage (spec 17 §4.1: they
extend and call PHPUnit, which the core only suggested).Testing\Conformance\CoreConformanceTestCaseand
OrmConformanceTestCasekeep their FQCN;Testing\KeyFileAssertions,Testing\CheckOutputAssertionsand
Testing\ReadmeAssertionsare nowTesting\Conformance\KeyFileAssertions,CheckOutputAssertions,
ReadmeAssertions— a withdrawal from the stable API before 1.0. Migration:composer require --dev indexnowkit/testingand change the threeuselines. The mock server is
vendor/indexnowkit/testing/resources/mock-server/router.php. The four test doubles (FakeTransport,ArrayLogger,
FrozenClock,RecordingDispatcher) stay in the core;phpunit/phpunitleavessuggest.Console\*is theindexnowkit/consolepackage (spec 17 §4.2: the runners render throughsymfony/console,
which the core only suggested; a plain-PHP or Doctrine user never needed them). The 16 classes keep their FQCN
(Console\*Runner,ResultRenderer,ResultFormatterInterface,SubjectLoaderInterface,SubmitSubjectsOptions,
Definitions,CommandDefinition,ArgumentDefinition,OptionDefinition,Vocabulary,ExitCode,
ClassNameResolver):composer require indexnowkit/consoleand nothing else changes — the framework adapters
require it for you.symfony/consoleleavessuggestandrequire-devof the core; nothing undersrc/imports
Symfony\Component\Console\orPHPUnit\any more.
Added
Adapter\OptionalPackage: one predicate for an optional package of the family (installed()=class_exists()
of a marker class unless overridden, no statics) with the three texts an adapter printed from its own copy:
notInstalledMessage(),checkLine()/checkLevel()/check()(ok without a block or with the shipped
defaults, warning when a configured block is ignored). Replaces theSitemapSupportcopies of Laravel and Yii2 and
the predicate of the bundle (spec 17 §4.3).Testing\ReadmeAssertions: what the "Notes for AI assistants" README section must keep (present in EN and RU, a
complete snippet, only existing commands and configuration keys), for theReadmeAiNotesTestof every package
(moved toindexnowkit/testingasTesting\Conformance\ReadmeAssertionsin the same release, see Changed)..phpstorm.meta.php: completion oflocales(current,all) andeventson#[IndexNow]/#[IndexNowDefaults].
Documentation
- README: "Notes for AI assistants" (package, minimal complete snippet, verification, pitfalls across the adapters).
- docs/configuration.md "One concept, three keys": the core keys with their defaults, the sitemap keys, the synonyms
(dispatchmodes,messenger.transport/queue.connection/queue.component,framework.enabled_locales/
router.locales/router.languages,doctrine.enabled/eloquent.enabled/active_record.enabled, key
file route, log destination, debounce store, HTTP client) and the adapter-only keys — generated by
bin/config-tablefromConfig::OPTIONS,SitemapConfig::OPTIONS, the bundle tree,LARAVEL_OPTIONSand
YII_OPTIONS, checked in CI. - docs/operations.md: the production checklist first; "What IndexNow is, and is not" (where to see the result);
"Deleted pages: what your site must return" (410/404/301, soft-404); "What not to submit" (noindex,
robots.txt, non-canonical URLs, drafts — what the library checks and what it cannot); the four monitoring rules
and a Sentrybefore_sendfilter; the 403 counter is per process; the key never travels in a query string. - docs/attribute-reference.md "Anti-patterns": five declarations that submit the wrong thing (
url:with a literal,
a status string inwhen:, a rule on anoindexpage, non-canonical URLs, nowhenon a model with drafts). - README and configuration.md:
batch.max_urlsis a ceiling, not a target. - Russian translations: docs/attribute-reference.ru.md, docs/configuration.ru.md (linked from README.ru.md).
homepagein composer.json points at the docs site (https://indexnowkit.github.io/php/).
0.6.0
Wave 0a of docs/spec/17: the one finding of the audits with irreversible consequences (a staging copy with the
production key submits real URLs while check says nothing), plus what bc.md allows in a minor. Additive; the
only behaviour change is a red check in the case described first. Adapters of this wave: symfony-bundle 0.7.0,
laravel 0.8.0, yii2 0.6.0, doctrine 0.4.0, sitemap 0.2.0.
Added
checkfails outside production when a key is configured anddry_runwas left unset:environment "staging" is not in production_environments but dry_run is off: changes WILL be sent to search engines under key ab12****. Set INDEXNOW_DRY_RUN=1 or INDEXNOW_ENABLED=0 outside production, or set dry_run: false explicitly if this environment submits on purpose.A preview environment that submits on purpose says so with an explicit
dry_run: false(the line becomes a warning); without an environment name (plain PHP withoutAPP_ENV/
INDEXNOW_ENV) nothing is judged. Anenvironment: …line is printed whenever the name is known. If a
pipeline runscheckon a staging copy that submits on purpose, it turns red after this upgrade: add
dry_run: falseto that environment's configuration (Laravel:INDEXNOW_DRY_RUN=0).Config::$dryRunExplicit: whetherdry_runcame from the configuration (fromArray()saw the key with a non-null
value; the constructor defaults to true;with(dryRun:)/withDryRun()set it, otherwith()changes keep it).Engine::InternetArchive(internetarchive) andEngine::Amazon(amazon), from the IndexNow registry
(searchengines.json, snapshot 2026-09-05; the direct Internet Archive host had no DNS record that day — it is
reached throughapi). docs/spec/01-protocol.md lists the registry.Config::fromEnv()readsINDEXNOW_PREVIOUS_KEY.checkends withNext: annotate a class with #[IndexNow(...)], or send one URL now: <cli> <submit> https://….Check\Checker: a mismatching key file body is printed (its first 60 bytes) with the usual cause, a catch-all
route answering 200 with HTML.
Fixed
- Debounce with several engines: a URL accepted by one engine and answered 429/5xx (or a transport failure) by
another was marked as submitted, so the retry of the failed engine hit the window and never left. The marked set
is now the successful URLs minusResult::retryableUrls(); a permanent refusal (403, 422) at one engine still
marks.engines: ['api'], dry_run and invalid URLs are unchanged. - Error texts say the fact, what is allowed and how to fix it: the
resolver.*andretry.*bounds (one message per
option, with the value),debounce.key_prefix(which characters PSR-6 reserves), the unknown-engine message
(mentionsengine_aliases),Console\ClassNameResolver(the namespaces searched; what the command needs the
class for),ParamExtractor(the accepted param kinds; the method and property names tried and the registered
readers).Retry\RetryingSubmitter's docblock states the sync/CLI path and points queue workers to
WorkerOutcome.
Documentation
- README: "Why this over X", "Notification, not indexing" (where to see the result, 410/404/301 for deleted and moved
pages, Bing URL Submission API and Google Indexing API are other protocols), Internet Archive and Amazon among the
notified engines, the issues link to the monorepo,key_file.enabledinstead ofserve_key_filein the table.
0.5.1
Wave C of docs/spec/16: indexnowkit/sitemap goes back to suggest in the adapters (symfony-bundle 0.6.0, laravel
0.7.0, yii2 0.4.0). The core gains the two pieces an adapter needs to keep an optional package optional; nothing changes
for applications.
Added
Check\StaticCheck(CheckLevel $level, string $line): one fixed line ofcheck, decided when the adapter is
wired —sitemap: not installed (composer require indexnowkit/sitemap)when the optional package is absent.Adapter\ConfigFactory(..., array $ignoreBlocks = []): top-level blocksunknownOptions()skips entirely, so a
configuration written for an optional package does not warn once the package is gone (ignoreBlocks: ['sitemap']).
Appended with a default: every existing call compiles. Block names only; a dotted key is aLogicException.- docs/adapters.md §2 "Optional packages": the predicate, the classes behind it, the stub command,
StaticCheck,ignoreBlocks; §20 lists the same as Definition of Done items.