Skip to content

Extending and replacing pieces

Every piece of the pipeline is a container service with an interface alias. Three ways to change behaviour, in the order you should try them:

  1. Configuration. Most knobs are in indexnowkit.yaml (configuration.md). Prefer it: the value is validated at compile time and shows up in indexnow:check.
  2. Decorate. Keep the shipped implementation, wrap it. Symfony's #[AsDecorator] on the service id below is enough; the bundle sees your class through the alias.
  3. Replace. Register your own implementation of the interface and alias the service id to it. Do this when the shipped implementation is the wrong model, not when you need to tweak it.

What is replaceable, and how

Piece Service id Interface Config knob Decorate / replace
HTTP client indexnowkit.transport.real (built by TransportFactory) PSR-18 ClientInterface or symfony HttpClientInterface http.client, http.timeout, http.user_agent point http.client at any client service: scoped client, proxy, RetryableHttpClient, extra headers
Transport indexnowkit.transport (lazy, wraps the real one) Http\TransportInterface; Http\StreamingTransportInterface for streamed GET decorate; see the streaming note below
Key provider indexnowkit.key_provider Key\KeyProviderInterface key, hosts, key_location, previous_key, strict_hosts replace for keys from a database or a vault; a rotation needs only previous_key
Key file endpoint indexnowkit.key_file_responder + KeyFileController Key\KeyFileResponder (final class) key_file.* disable it and serve the file yourself
URL normalizer indexnowkit.url_normalizer Url\UrlNormalizerInterface base_url, max_url_length decorate to strip tracking parameters, force a canonical host, or allow only some paths (throw InvalidUrlException: the URL becomes a skipped/invalid_url result). This is the one hook every path shares, including --force / --dry-run commands
URL resolver indexnowkit.url_resolver Url\UrlResolverInterface #[IndexNow] rules, resolver.max_via_depth, resolver.max_via_fanout decorate, or add resolvers referenced by #[IndexNow(resolver:)] (custom-resolvers.md)
Attribute reader indexnowkit.attribute_reader Attribute\AttributeReaderInterface decorate with RuleRegistry to register rules at runtime
Guarded resolver / change handler indexnowkit.guarded_url_resolver, indexnowkit.change_handler Url\GuardedUrlResolver, Url\ObjectChangeHandler (final) consume them; the ORM listener and the commands do
Client (HTTP half) indexnowkit.client ClientInterface engines, hosts.<host>.engines, batch.max_urls, logging.* decorate for per-host policy or metrics at request level
Submitter indexnowkit.submitter SubmitterInterface debounce.*, dry_run, enabled decorate (RetryingSubmitter is an example); listeners via PSR-14 events. Note: --force / --dry-run commands build their own Submitter through SubmitterFactory and bypass a decorator here (they do fire the PSR-14 events)
Collector indexnowkit.collector Collector\CollectorInterface collector.max_urls, collector.detect_leaks replace for a different per-request store
Dispatcher indexnowkit.dispatcher Dispatch\DispatcherInterface dispatch, messenger.transport, messenger.delay, messenger.stamps replace for another queue (dispatch: none + your own drain of the collector). A decorator cannot add stamps after the fact: use messenger.stamps
Debounce store indexnowkit.debounce_store Debounce\DebounceStoreInterface debounce.store (any PSR-6 pool, memory, none), debounce.key_prefix replace
Throttle indexnowkit.throttle Throttle\ThrottleInterface throttle.max_requests_per_minute replace for a shared (Redis) limiter
Clock indexnowkit.clock PSR-20 Psr\Clock\ClockInterface (default Clock\SystemClock) replace to move every time the graph reads at once: the throttle window, the memory debounce window, the time a submission record gets and the pre-flight decorator. IndexNowKit\Testing\FrozenClock in tests (testing.md)
Sitemap source indexnowkit.sitemap_reader Sitemap\SitemapSourceInterface sitemap.* decorate to filter or rewrite entries; replace to read from another place or format. Registered only with indexnowkit/sitemap installed and sitemap.enabled: true (the default)
Entity loader (commands) indexnowkit.entity_loader indexnowkit/console Console\SubjectLoaderInterface decorate for soft deletes, tenant scoping, another id format (byIds() / all() receive the Event). Registered only when the Doctrine integration is active (doctrine.md)
Command submitter (--force, --dry-run) indexnowkit.command_submitter_factory core Adapter\SubmitterFactoryInterface decorate to wrap what the manual commands submit through
Command output indexnowkit.result_formatter indexnowkit/console Console\ResultFormatterInterface replace to match your CLI's JSON envelope or table style
Command bodies indexnowkit.console.* indexnowkit/console Console\*Runner the commands (the classes of indexnowkit/console, sitemap and history, see "Replacing a command") are input parsing over these; reuse a runner from your own command (a tenant loop over SubmitSubjectsRunner)
Configuration source of check / config indexnowkit.console.config_source indexnowkit/console Console\ConfigSourceInterface (DependencyInjection\ConsoleConfigSource) what the two commands read: the processed tree, its strict build, the package blocks
indexnow:check indexnowkit.checker Check\CheckerInterface; add lines with Check\CheckInterface services (autoconfigured) add checks rather than replacing the checker
Key file route indexnowkit.key_file_routes Routing\KeyFileRouteLoader key_file.path, key_file.host, key_file.route_name do not import config/routes.php and register your own route to KeyFileController
Doctrine listener indexnowkit.doctrine.listener IndexNowListener (final) doctrine.* disable and write your own on top of ObjectChangeHandler (created(), updated(), deleted(), renamed()); skip a namespace by decorating the attribute reader to return an empty RuleSet
Flush timing indexnowkit.flush_listener EventListener\FlushListener flush.priority, flush.console_priority order against your own terminate listeners
Logging every service, channel logging.channel PSR-3 logging.channel, logging.levels, logging.max_urls, logging.forbidden_escalation route the channel in monolog.yaml; per-outcome levels need no code

Not replaceable on purpose: the spool the sitemap reader parses through (Sitemap\Spool), the rule compiler and the result/reason value objects. They are the parts a wrong replacement would silently break, and they have no behaviour worth swapping: configure the spool (sitemap.spool, sitemap.spool_dir) instead.

Decorating the sitemap source

Keep the shipped reader (fetching, gzip, indexes, retries, spooling) and shape what comes out of it:

use IndexNowKit\Sitemap\SitemapSourceInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;

#[AsDecorator('indexnowkit.sitemap_reader')]
final class PublicOnlySitemapSource implements SitemapSourceInterface
{
    public function __construct(#[AutowireDecorated] private readonly SitemapSourceInterface $inner) {}

    public function read(string $sitemap, ?\DateTimeImmutable $changedSince = null): iterable
    {
        foreach ($this->inner->read($sitemap, $changedSince) as $entry) {
            if (!str_starts_with($entry->url, 'https://www.example.com/private/')) {
                yield $entry;
            }
        }
    }
}

Yield, do not collect: the command submits every batch.max_urls entries while your generator is still running. A replacement that reads from a database or a search index implements the same interface; $sitemap is then whatever the command was given (or sitemap.url), free for you to interpret. --allow-foreign-hosts reaches only the shipped reader, the command warns when it is passed to another source.

The shipped reader also accepts a local path or file:// URL: a sitemap the application writes into public/ can be read without the web server (bin/console indexnow:sitemap /var/www/public/sitemap.xml). Its index parts must then be local files too, or the run needs --allow-foreign-hosts to fetch them by URL.

Decorating the transport

use IndexNowKit\Http\Response;
use IndexNowKit\Http\StreamingTransportInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;

#[AsDecorator('indexnowkit.transport')]
final class MeteredTransport implements StreamingTransportInterface
{
    public function __construct(#[AutowireDecorated] private readonly StreamingTransportInterface $inner, private readonly Meter $meter) {}

    public function post(string $url, string $json, array $headers = []): Response
    {
        $this->meter->count('indexnow.post');

        return $this->inner->post($url, $json, $headers);
    }

    public function get(string $url): Response
    {
        return $this->inner->get($url);
    }

    public function download(string $url, $sink): Response
    {
        return $this->inner->download($url, $sink);
    }
}

Implement StreamingTransportInterface, not only TransportInterface: a decorator without download() still works, but the sitemap reader then falls back to get() and buffers each document once (up to 50 MiB) before spooling it. Most needs (proxy, retries, headers, timeouts, mTLS) belong in http.client rather than in a transport decorator.

Replacing the key provider

use IndexNowKit\Key\KeyProviderInterface;

final class VaultKeyProvider implements KeyProviderInterface { /* keyFor(), keyLocationFor(), isKnownKey(), managedHosts() */ }
services:
    App\IndexNow\VaultKeyProvider: ~
    indexnowkit.key_provider: '@App\IndexNow\VaultKeyProvider'

indexnow:check, the key file controller, the profiler panel and explain all go through the alias, so they report your keys.

Submitting from your own domain events

Nothing here needs Doctrine. Call the facade from the service that knows the page changed (a CMS publish action, a MongoDB document listener, an import): $indexNow->submitEntity($page, Event::Updated) resolves the URLs from the #[IndexNow] rules of the object and submits at once; $indexNow->collect($urls) + the request-end flush delivers through the configured dispatcher instead. Objects that cannot carry attributes get their rules from RuleRegistry::registerFor() (decorate indexnowkit.attribute_reader).

Adding lines to indexnow:check

A service implementing Check\CheckInterface is tagged indexnowkit.check by autoconfiguration and runs after the built-in checks; it adds lines, it cannot throw (an exception becomes an error line naming the class):

use IndexNowKit\Check\CheckInterface;
use IndexNowKit\Check\CheckReport;

final class TenantKeysCheck implements CheckInterface
{
    public function __construct(private readonly TenantRepository $tenants) {}

    public function check(CheckReport $report): void
    {
        $missing = $this->tenants->withoutIndexNowKey();
        $missing === [] ? $report->ok('tenants: every active tenant has a key') : $report->error(\sprintf('tenants without a key: %s', implode(', ', $missing)));
    }
}

Replacing a command

The indexnow:* commands are classes of the packages, not of the bundle (since 0.15.0): IndexNowKit\Console\Command\* of indexnowkit/console (check, config, submit, submit-entity as SubmitSubjectsCommand, explain, key:generate, the three "not installed" stubs), IndexNowKit\Sitemap\Console\SitemapCommand, IndexNowKit\History\Console\HistoryCommand and StatusCommand. The bundle registers each under its class name as the service id, with the runner (indexnowkit.console.*), the vocabulary, DependencyInjection\ConsoleConfigSource (what check and config read), %kernel.project_dir%/.env.local for key:generate and indexnowkit.check.samples as arguments. To change what a command does, decorate the runner, not the command — that is the body:

services:
    App\IndexNow\TenantAwareSubmitEntity:
        decorates: indexnowkit.console.submit_entity      # a SubmitSubjectsRunner that loops over the tenants
        arguments: ['@.inner']

To replace a command outright, register your own under the same name; the last console.command registered for a name wins, and IndexNowKit\Console\Definitions gives it the same arguments and options:

#[AsCommand(name: 'indexnow:submit')]
final class SubmitThroughGatewayCommand extends Command
{
    protected function configure(): void
    {
        Definitions::submit()->applyTo($this);
    }
}

SubmitSubjectsCommand carries no #[AsCommand] (its name is the vocabulary's, indexnow:submit-entity here), so the bundle registers it with the command and description attributes of the tag; a replacement of that one names itself the same way. Every other command is lazy through its attribute; the bundle's own test keeps every console.command lazy.

Conformance tests for your integration

IndexNowKit\Testing\Conformance\CoreConformanceTestCase (shipped by the core, needs PHPUnit) runs the protocol scenarios of the spec (C01, C03, C04, C06, C09 to C12, C14, C19, C20) against the facade your container built. The bundle's own tests/Functional/CoreConformanceTest.php is the reference: boot your kernel, return indexnowkit and the FakeTransport aliased as indexnowkit.transport, optionally a second configured host. Run it in the application that decorates or replaces bundle services to prove the pipeline still conforms.

Listening instead of replacing

Submitter dispatches every Result as a PSR-14 event through event_dispatcher when it exists; a listener on IndexNowKit\Result gets each outcome for metrics or alerting without touching the pipeline (operations guide).

What is the core's

SubmitUrlsHandler is Retry\WorkerOutcome (which URLs to retry, which were rejected for good, the log lines) plus RecoverableMessageHandlingException with the engine's Retry-After. The commands configure their arguments and options from Console\Definitions and Sitemap\Console\Definitions (CommandDefinition::applyTo($command)), so bin/console indexnow:submit-entity --help matches artisan and Yii2; a custom command over a core runner can call the same applyTo().