Skip to content

Releases

0.13.1

Choose a tag to compare

@somework somework released this 07 Sep 20:07

Added

  • Config::arrayFromEnv(?array $env = null, string $prefix = 'INDEXNOW_'): array (wave N, spec 19b §3.3): the
    INDEXNOW_* variables fromEnv() reads, as the nested array fromArray() 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 now fromArray(arrayFromEnv()); Config::fromArray(Config::arrayFromEnv($env)) equals
    Config::fromEnv($env), and a test keeps it so. The indexnow CLI (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.14 would force a release of every package that requires core ^0.13, for one method none of them calls.

0.13.0

Choose a tag to compare

@somework somework released this 07 Sep 17:28

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 a Class "IndexNowKit\Sitemap\Adapter\SitemapServices" not found at 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 passed installed: false with the packages still installed, so they did not
    catch it). docs/adapters.md §2 "Optional packages" names the new recipe; docs/bc.md lists Adapter\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() and noRequestHost() — the two ConfigurationExceptions, one text each (four bridges had
    four spellings of Cannot generate route). Dispatch\BatchingDispatcher(Closure $enqueue, Config, ?Logger, string $noun = 'job'): one unit per batch.max_urls, newJobId() (twelve hex characters: three adapters had
    bin2hex(random_bytes(6)) each), a push that throws logged as cannot queue {count} URL(s) ({noun} {id}), they are lost with Config::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): the sync / none
    sentence of the adapters' queue checks.
  • Debounce\DebounceStoreFactory::isShared(?string $store): whether debounce.store names a cache shared by
    every process, the question nine places across five packages answered with their own in_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 wrote indexnowkit:check, and : is a character PSR-16 reserves,
    so a strict cache (yiisoft/cache) refused the probe and check reported a working store as broken. The docblock of
    the check says "writes a test key" now; two probes only read one.
  • Adapter\OptionalPackage knows 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 by sitemap(), verify(), history(); options() calls it only when
    installed() says yes; the static ownedOptions(list<OptionalPackage>) and ignoredBlocks(list<OptionalPackage>)
    fold a list of predicates into the two arguments of Adapter\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() and Http\TransportFactory::lazy() take the application's PSR-17 factories
    (?RequestFactoryInterface $requestFactory, ?StreamFactoryInterface $streamFactory, appended and optional): a
    PSR-7 stack with a Psr17Factory service of its own (Slim, Mezzio, Yii3) hands it over instead of having
    php-http/discovery find one; null discovers, as before. The docblock of sendRequest() 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>.txt over PSR-7 for any PSR-15 stack — a
    RequestHandlerInterface (the action of a route: the key is read off the path), a MiddlewareInterface (in front
    of the router: a request it does not serve goes on to the next handler untouched) and respond(?string $key, $request)
    for a key the router already extracted. The body is KeyFileResponder's, the headers are Config::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-handler and
    psr/http-server-middleware join require (interfaces only; the core stays PSR-only).
  • Adapter\SubmitterFactory takes ?ClockInterface $clock as its last parameter and passes it to the Submitter
    it builds; Adapter\Services::submitterFactory() gives it the graph's clock node (A1). Appended and optional, so
    the "call" tier holds. Without it the clock node stopped at the application's submitter: Testing\FrozenClock left
    submit, submit-record and sitemap on 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() and requireResolverLocator() (A9): the same nodes as router() and
    resolverLocator(), but typed non-nullable and throwing a ConfigurationException that names the missing node. An
    adapter that always wires one wrote ?? throw with a message nobody ever read; two adapters had shipped that dead
    branch before this existed.
  • The events closure of Adapter\ServicesBuilder may return null (A8), like the nullable nodes. An adapter
    whose container may or may not hold a Psr\EventDispatcher\EventDispatcherInterface now answers that inside the
    closure instead of asking the container during build(), which is documented to do no IO.
  • Check\SampleGateCheck over Check\SampleOptions (A2): the --sample / --sample-class gate in front of the
    optional indexnowkit/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.md had documented verify.sample
    for it, a code that exists only while the package does (D1).
  • Hook\ObserverHelper::forKit() next to forChanges() (A23): the plain case as a named constructor, so the
    union-typed constructor with its conditionally required $sink is 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\ArrayResolverLocator wraps a throwing container lookup in a ConfigurationException naming 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 hand locate: a bare lookup now.

Fixed

  • PHP 8.5 without deprecations, phpstan on PHP 8.4+ clean — Url\ViaWalk uses the SplObjectStorage offset methods
    (attach()/contains() are deprecated since 8.5), Url\UrlNormalizer decodes a percent-escape with hex2bin() and
    Attribute\RuleCompiler::compile() is @template T of object over its `ReflectionCla...
Read more

0.12.0

Choose a tag to compare

@somework somework released this 06 Sep 18:50

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\FieldCondition no longer extends Condition, and Equals::evaluate() is gone. A field condition
    is read by the graph's ParamExtractor (heldFor() of the value it reads for field()), so it never reads the object
    itself; evaluate() built a plain ParamExtractor inside and answered wrong on Eloquent attributes. when accepts
    string|Condition|FieldCondition|Closure. To evaluate one by hand: $extractor->condition($subject, $equals). A custom
    FieldCondition drops its evaluate() (an extra method is harmless); a custom Condition is 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 to new 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 without resolver: build AttributeUrlResolver::fromConfig(), as
    Adapter\ServicesBuilder does, instead of NullUrlResolver: a class with #[IndexNow] handed to the bare facade resolves
    its url/urls rules (a route: 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 of submitEntity() (the submitX/submitXs scheme of every adapter:
    submitModels(), submitRecords()). submitAll() is its @deprecated alias, 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) — $previous is the contract
    for adapters whose objects cannot be reset by reflection (Eloquent); Attribute\SubjectReaderInterface stays read-only,
    no write() is planned. docs/attribute-reference.md: conditions rewritten for the split.

0.11.0

Choose a tag to compare

@somework somework released this 06 Sep 17:54

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\AttributeUrlResolver via walk: 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
    ConfigurationException that dropped everything the rule had collected.
  • IndexNowKit derives its extractor from the resolver (Url\ParamExtractorAwareInterface, implemented by
    AttributeUrlResolver; GuardedUrlResolver::inner()): a custom resolver built with readers made the change handler
    and explain read when with the plain DSL and fail silently. The constructor parameter is ?ParamExtractor now,
    null = the resolver's; $extractor stays 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, so User-agent: * / Disallow: /private/ plus User-agent: bingbot / Allow: / no
    longer blocks the verify pre-flight for Bing. Each IndexNow engine is evaluated with its own group.
  • Throttle\TokenBucket credits 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 optional ClockInterface.
  • 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 of commit() told the application its commit failed.
  • Url\ObjectChangeHandler puts every field back when restoring a previous state even if one setValue() throws
    (a PHP 8.4 set hook), then rethrows.
  • Retry\WorkerOutcome counts a retryable skipped result (the verify pre-flight on an origin error) as retryable,
    not as a final failure.
  • Http\Response::parseRetryAfter() understands several Retry-After headers 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
    ConfigurationException with the usual hint) instead of a raw Error.
  • Client masks the previous key of the host in log excerpts too, not only the current one.
  • Url\CanonicalUrlNormalizer decodes + as a space when matching tracking parameter names.
  • Testing\FakeTransport has a $beforeGet hook (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 through kit(), which built everything). Adapter\Services::clock() /
    ServicesBuilder::clock()
    and IndexNowKit::create(clock:): one clock for the throttle, the debounce store and the
    submission timestamps (Testing\FrozenClock in an adapter's tests). ServicesBuilder::events() accepts a
    Closure
    (called on first use). A closure node may return null for the nullable nodes (failureCache,
    submissionStore, router, resolverLocator). Services::events() returns the dispatcher.
  • Hook\ObserverHelper::forChanges(ObjectChangeHandler $changes, Closure $sink) (and the constructor accepts an
    ObjectChangeHandler with a $sink): the helper over a change handler alone, for a graph that builds lazily.
  • Http\Psr18Transport::discover(..., ?int $getBodyLimit) and Http\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: RouteUrlResolverInterface and ResolverLocatorInterface are "Implement" (the core ships nothing to
    decorate); Attribute\Param\ParamValue is a sealed set; symfony/polyfill-intl-idn is suggested for hosts the
    built-in punycode fallback maps incompletely without ext-intl.

0.10.0

Choose a tag to compare

@somework somework released this 06 Sep 12:22

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\ParamExtractor is an instance (tier Call; constructors take named arguments): new ParamExtractor(...$readers)
    holds the SubjectReaderInterfaces of the graph; extract(), read(), resolve() and condition() 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 static registerReader() / 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::class in Laravel, indexnowkit.param_extractor in Symfony, ServicesBuilder::paramExtractor()
    in Yii2) and their EloquentSubjectReader / ActiveRecordSubjectReader go in there.
  • A FieldCondition is evaluated through the extractor: ParamExtractor::condition() reads field() with its readers
    and asks heldFor(), so new Equals('status', 'published') sees an Eloquent attribute the same way params does; a plain
    Condition still runs evaluate($subject). Equals::evaluate() alone reads with the plain DSL (it was the static
    registry before); the core never calls it for a FieldCondition. 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 of IndexNowKit (public readonly ParamExtractor $extractor), Url\AttributeUrlResolver (constructor and
    fromConfig(), plus extractor()), 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() and Adapter\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 under docs/bc.md).

0.9.0

Choose a tag to compare

@somework somework released this 06 Sep 10:48

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 of Client as a class of its own (tier Call):
    hit(host): array{int, bool} (the new count and whether this hit crosses logging.forbidden_escalation once 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> / …_escalated with 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.
    Client builds 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 — what indexnow:status of indexnowkit/history prints per host.
  • Http\TransportFactory::lazy(..., array $extraHeaders = []) and Http\Psr18Transport::discover(..., array $extraHeaders = []) (appended): headers sent with every request of that transport. TransportInterface::get()
    takes no headers, so this is how indexnowkit/verify sends its own User-Agent on the pre-flight GETs.

0.8.0

Choose a tag to compare

@somework somework released this 05 Sep 19:25

Added

  • Stable codes on every check line (spec 17 §5.1). Check\CheckItem carries ?string $code and ?string $host
    (appended, defaults null); CheckReport::ok()/warning()/error() take them as appended optional arguments, and
    CheckReport::add(), hasWarnings() and status() (the worst level) are new. Every line of Check\Checker,
    Check\DebounceStoreCheck (DebounceStoreCheck::CODE) and Adapter\OptionalPackage::check() (checkCode():
    <feature>.installed) names its check; Check\StaticCheck takes 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\Response exposes 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, else max-age; no-store/no-cache are 0) and age(). Http\Psr18Transport::get(),
    post() and download() fill them; Testing\FakeTransport::download() passes them through. A custom transport
    that leaves them empty keeps working: check prints one neutral line instead of a verdict.
  • New check lines (spec 17 §5.1): key_file.content_type (error when the key file is served as something other
    than text/plain), key_file.cache_control (warning when Cache-Control/Age exceed key_file.cache_max_age),
    key_file.robots (warning when robots.txt disallows the key file path for every bot or an engine's bot;
    Checker::robotsDisallows() is public), key_file.previous (with previous_key: the old key file still served is
    the open rotation window, otherwise a warning) and the global http.client warning about redirects through a
    custom client. check now 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 the critical line once per streak; a
    cache with increment() 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\SubmitterFactory takes the
    cache as an appended argument, Adapter\ServicesBuilder::failureCache() / Services::failureCache() carry it,
    IndexNowKit::create(..., failureCache:) passes it. Check\Checker probes without it.
  • Submission\SubmissionStoreInterface (spec 17 §5.4): record(Result, DateTimeImmutable), recent(limit, host, status), lastFor(url); Submission\SubmissionRecord (urls, result, at) and Submission\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 through Adapter\SubmitterFactory (appended $store),
    Adapter\ServicesBuilder::submissionStore() / Services::submissionStore() and IndexNowKit::create(..., submissionStore:). Implement tier; the "state → records" table is docs/submission-store.md.
    The indexnowkit/history package will ship the stores.
  • Canonical URLs (spec 17 §5.5): Url\CanonicalUrlNormalizer decorates 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. Options normalizer.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 used new UrlNormalizer(...) (Submitter, Client, IndexNowKit::create(),
    Adapter\Services, Check\Checker), so the options work in plain PHP too.
  • Attribute\Param\Condition and FieldCondition (spec 17 §5.6): when takes a string accessor, a Condition
    (evaluate(object): bool — Equals, or your own class) or a closure. A FieldCondition (field(),
    heldFor($oldValue)) lets ChangeClassifier read the old state from the ORM change set; a plain Condition is
    evaluated on the current object and cannot see an unpublish unless whenFields names the field (documented in
    attribute-reference.md). ParamExtractor::condition() takes string|Condition|Closure; UrlRule::accessorOf()
    reads a FieldCondition's field.
  • Config::toArray(): the effective configuration in the nested shape fromArray() takes, every option present with
    its resolved value (Config::fromArray($config->toArray()) is an equal configuration); keys are not masked, the
    config command does that.
  • Reason cases for the verify package (spec 17 §5.7; the enum is closed, so the core reserves them): Noindex,
    RobotsDisallowed, NonCanonical, Redirected (skips, not retryable) and OriginError (skip, retryable), with
    messages and translation keys; Reason::isRetryable() is new. The table of every case is in docs/bc.md. A match
    over Reason without a default arm fatals on them, as documented.

Changed

  • Tracking parameters are stripped by default (normalizer.strip_tracking_params: true): a URL with utm_*,
    gclid, fbclid, … is submitted and debounced as its clean form. The debounce keys of such URLs change once;
    URLs without those parameters are unaffected. Set normalizer.strip_tracking_params: false to keep the old
    behaviour.
  • Attribute\Param\Equals is a FieldCondition, not a ParamValue. params: ['x' => new Equals(...)] — which
    never produced a meaningful value — is now a ConfigurationException from ParamExtractor naming the fix (Equals
    belongs to when). when is typed string|Condition|Closure|null in IndexNow, IndexNowDefaults,
    IndexNowUrl, UrlRule, RuleCompiler, ParamExtractor::condition() and ChangeClassifier: a ParamValue
    (Accessor, Value, Formatted, Call) in when is a TypeError. Migration: when: new Accessor('x') →
    when: 'x'; anything else in when becomes a Condition.

0.7.0

Choose a tag to compare

@somework somework released this 05 Sep 17:24

Changed

  • Console\SubmitterFactory and Console\SubmitterFactoryInterface are Adapter\SubmitterFactory and
    Adapter\SubmitterFactoryInterface; Console\ResultSummary is Submission\ResultSummary
    (spec 17 §4.2: they are
    called from Adapter\Services and the adapters, not only from the commands, so they stay in the core when the
    commands move to indexnowkit/console). Migration: change the use lines —
    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 the indexnowkit/testing package (spec 17 §4.1: they
    extend and call PHPUnit, which the core only suggested). Testing\Conformance\CoreConformanceTestCase and
    OrmConformanceTestCase keep their FQCN; Testing\KeyFileAssertions, Testing\CheckOutputAssertions and
    Testing\ReadmeAssertions are now Testing\Conformance\KeyFileAssertions, CheckOutputAssertions,
    ReadmeAssertions — a withdrawal from the stable API before 1.0. Migration: composer require --dev indexnowkit/testing and change the three use lines. 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/phpunit leaves suggest.
  • Console\* is the indexnowkit/console package (spec 17 §4.2: the runners render through symfony/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/console and nothing else changes — the framework adapters
    require it for you. symfony/console leaves suggest and require-dev of the core; nothing under src/ imports
    Symfony\Component\Console\ or PHPUnit\ 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 the SitemapSupport copies 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 the ReadmeAiNotesTest of every package
    (moved to indexnowkit/testing as Testing\Conformance\ReadmeAssertions in the same release, see Changed).
  • .phpstorm.meta.php: completion of locales (current, all) and events on #[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
    (dispatch modes, 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-table from Config::OPTIONS, SitemapConfig::OPTIONS, the bundle tree, LARAVEL_OPTIONS and
    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 Sentry before_send filter; 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 in when:, a rule on a noindex page, non-canonical URLs, no when on a model with drafts).
  • README and configuration.md: batch.max_urls is a ceiling, not a target.
  • Russian translations: docs/attribute-reference.ru.md, docs/configuration.ru.md (linked from README.ru.md).
  • homepage in composer.json points at the docs site (https://indexnowkit.github.io/php/).

0.6.0

Choose a tag to compare

@somework somework released this 05 Sep 07:41

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

  • check fails outside production when a key is configured and dry_run was 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 without APP_ENV /
    INDEXNOW_ENV) nothing is judged. An environment: … line is printed whenever the name is known. If a
    pipeline runs check on a staging copy that submits on purpose, it turns red after this upgrade
    : add
    dry_run: false to that environment's configuration (Laravel: INDEXNOW_DRY_RUN=0).
  • Config::$dryRunExplicit: whether dry_run came from the configuration (fromArray() saw the key with a non-null
    value; the constructor defaults to true; with(dryRun:) / withDryRun() set it, other with() changes keep it).
  • Engine::InternetArchive (internetarchive) and Engine::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 through api). docs/spec/01-protocol.md lists the registry.
  • Config::fromEnv() reads INDEXNOW_PREVIOUS_KEY.
  • check ends with Next: 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 minus Result::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.* and retry.* bounds (one message per
    option, with the value), debounce.key_prefix (which characters PSR-6 reserves), the unknown-engine message
    (mentions engine_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.enabled instead of serve_key_file in the table.

0.5.1

Choose a tag to compare

@somework somework released this 04 Sep 20:10

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 of check, 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 blocks unknownOptions() 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 a LogicException.
  • 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.