Search by

indexnowkit / symfony-bundle

Symfony bundle for IndexNow: notify Yandex, Bing, Naver, Seznam and Yep when Doctrine entities change. #[IndexNow] rules on entities, commit-safe Doctrine hooks, Messenger dispatch, key file route, console commands, profiler panel.

Maintainers

Package info

github.com/indexnowkit/php-symfony-bundle

Issues

Documentation

Type:symfony-bundle

pkg:composer/indexnowkit/symfony-bundle

Transparency log

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 0

0.3.0 2026-09-03 20:20 UTC

This package is auto-updated.

Last update: 2026-09-03 20:42:48 UTC


README

Tell search engines about new, changed and deleted pages the moment a Doctrine entity is committed. One attribute on the entity, one env variable, done.

Packagist Downloads CI Conformance PHP Symfony

Русская версия

Who gets notified

Yandex, Bing (and DuckDuckGo via Bing), Naver, Seznam, Yep — every engine that implements the IndexNow protocol. One request to the shared endpoint reaches all of them.

Google: no. Google does not support IndexNow, its sitemap ping endpoint is gone (404) and the Indexing API is restricted to JobPosting / BroadcastEvent. This bundle will not pretend otherwise.

Install

composer require indexnowkit/symfony-bundle
composer require symfony/http-client nyholm/psr7  # any PSR-18 client works; this pair is auto-configured
composer require indexnowkit/doctrine        # for automatic submission when entities change
bin/console indexnow:key:generate --write-env     # adds INDEXNOW_KEY to .env.local

The Flex recipe registers the bundle, creates config/packages/indexnowkit.yaml and imports the key file route. Without Flex, add IndexNowKit\SymfonyBundle\IndexNowKitBundle to config/bundles.php and import @IndexNowKitBundle/config/routes.php from config/routes.yaml.

# config/packages/indexnowkit.yaml
indexnowkit:
    key: '%env(INDEXNOW_KEY)%'
    base_url: '%env(INDEXNOW_BASE_URL)%'   # used by console commands and Messenger workers

Entity hooks need indexnowkit/doctrine and doctrine/doctrine-bundle. Without them the bundle still works for manual submission, and indexnow:check says so instead of failing silently.

Declare what has a public page

#[IndexNow] is repeatable: one attribute per family of public URLs the entity has.

use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults};

#[ORM\Entity]
#[IndexNowDefaults(when: 'isPublished', fields: ['slug', 'title', 'body', 'published'])]
#[IndexNow(route: 'post_show', params: ['slug' => 'slug'])]
#[IndexNow(route: 'post_amp', params: ['slug' => 'slug'], when: 'hasAmp')]
#[IndexNow(via: 'category')]      // a changed post also refreshes its category page
#[IndexNow(urls: ['/'])]          // and the homepage
class Post { /* ... */ }
Option Meaning
route / params route name and param => property, getter, "self", dotted.path or a typed Param\* value
resolver a UrlResolverInterface service id or class for anything custom
via an accessor to a related object or collection whose pages are resubmitted
url / urls an accessor returning the URL(s), or literal URLs
when / whenFields bool accessor; unpublished entities are skipped and published → draft is sent as a deletion
fields for updates, submit only when one of these fields changed
events subset of created, updated, deleted
locales current (default), all (every framework.enabled_locales), or a list
host generate this rule's URLs on another host (multi-domain)
name stable rule id for logs, indexnow:explain and overriding in a subclass

Full model, typed parameters, inheritance and the semantics table: core attribute reference.

Verify

bin/console indexnow:check          # config, key file reachable, engines, dispatch, Doctrine hooks
bin/console indexnow:check --live   # also sends a real probe request to every engine

Run it after every key rotation and after every deployment that touches the configuration. It is the command that answers most "it does not work" reports on its own.

How it works

  • URLs are collected in onFlush / postFlush and handed over only after the outermost transaction commits (a DBAL driver middleware watches the real COMMIT). Rolled-back changes are never submitted.
  • Every rule of an entity is classified separately: the article page can be an update while the AMP page of the same entity is a deletion, in the same flush.
  • Everything collected during one HTTP request, console command or Messenger message is sent as one batch after the response was sent (kernel.terminate), never inside your request.
  • dispatch: auto uses Messenger when a transport is configured, otherwise sends synchronously after the response. sync always sends on terminate. none collects and never sends, for applications that drain the collector themselves.
  • The same URL is not re-sent within 10 minutes (debounce.per_url, stored in cache.app), batches are split at 10 000 URLs, hosts are grouped, 202 is a success, 403 means the key file is wrong.
  • Failures are logged on the indexnow Monolog channel and never break your request. http.timeout (10 s) and throttle.max_requests_per_minute (60, per process) apply to the HTTP client the bundle builds on first use.

Manual submission

public function __construct(private readonly IndexNowKit\IndexNowKit $indexNow) {}

$this->indexNow->submit(['/posts/hello', 'https://www.example.com/about']);
$this->indexNow->submitEntity($post);
$this->indexNow->explain($post, IndexNowKit\Event::Updated);   // which rule produced which URL

Commands

Command Options
indexnow:check --live send a real probe · --host check one host only · --probe-url page to probe when the root redirects
indexnow:submit <urls...> -f, --force ignore the debounce store · --dry-run · --json
indexnow:submit-entity <class> [ids...] --event=updated, created or deleted · --limit (default 1000, when no ids) · --explain show rule → URL and send nothing · -f, --force · --dry-run · --json
indexnow:explain <class> <id> --event=updated, created or deleted
indexnow:sitemap [sitemap] --changed-since="1 day" · --allow-foreign-hosts follow CDN-hosted parts · -f, --force · --dry-run list only · --json
indexnow:key:generate -l, --length (8-128, default 32) · --alphanumeric · --write-env[=FILE] (default .env.local) · --force rotate an existing key

indexnow:sitemap with no argument reads sitemap.url, else <base_url>/sitemap.xml; a local path or file:// URL reads the file without the web server. XML and text sitemaps, indexes and gzip are handled; the command streams and submits every batch.max_urls URLs, so size is not a concern. sitemap.enabled: false removes the command; decorating indexnowkit.sitemap_reader shapes what it submits (docs/extending.md). <class> accepts an FQCN or a short App\Entity name. indexnow:submit-entity and indexnow:explain need Doctrine.

Configuration

The full annotated tree, every default and every compile-time validation: docs/configuration.md.

Topic
Multiple domains docs/multi-domain.md
Async delivery and retries docs/messenger.md
HTTP client, proxy, scoped clients docs/http-client.md
Doctrine details, priorities, connections docs/doctrine.md
Custom resolvers docs/custom-resolvers.md
Extending: what is replaceable, decorating services docs/extending.md
Testing your integration docs/testing.md
Troubleshooting docs/troubleshooting.md

Debugging

Three tools, in the order you should reach for them.

  1. bin/console indexnow:explain App\Entity\Post 42 walks the whole decision path for one entity — rules, event subscription, when guard, fields filter, resolved URLs, normalization, host and key, key file, debounce — and sends nothing.
  2. The Web Profiler panel shows what the request collected, what was actually sent, and the HTTP outcome per engine, alongside the dispatch mode, the key file URL per host and the debounce window.
  3. The indexnow Monolog channel carries everything. Set it to debug while diagnosing: the reason a rule decided not to produce a URL is logged there. Message texts and levels are listed in the operations guide.

An invalid configuration does not throw from a flush: IndexNow is disabled, one critical line is logged, and indexnow:check prints the exact error.

Limitations

  • DQL and QueryBuilder bulk UPDATE / DELETE bypass the unit of work: use indexnow:submit or $indexNow->submit().
  • Sub-domains are separate hosts: give each its own key with the hosts map, and set strict_hosts: true so a host you did not configure is skipped rather than announced under the default key.
  • dispatch: sync depends on kernel.terminate actually firing. An early exit(), a fatal error, or a worker runtime whose bridge does not dispatch it per request will discard the batch — with a warning. Under Swoole, RoadRunner or FrankenPHP prefer dispatch: messenger.
  • Long-running custom commands should call $indexNow->flush() periodically instead of accumulating URLs for the whole process lifetime.
  • Outside production (production_environments, default prod/production), a missing INDEXNOW_KEY switches dry_run on instead of failing, so dev and test never hit the real API.
  • A renamed page (changed slug) announces its old URL as deleted and the new one as updated in the same flush; an entity whose slug is a readonly property only gets the new URL (logged at debug).

Compatibility

Public API of the bundle: configuration nodes, command names and options, service ids and aliases listed in docs/extending.md, the core's Console\*Interfaces they are aliased to, the Messenger message and handler, and the container parameters listed in docs/configuration.md. DependencyInjection\* is wiring, not API. The core's rules apply, including the "may grow" interfaces: bc.md. Before 1.0 a minor version may break; every break is listed under "Changed" in CHANGELOG.md with the migration.

Other frameworks

PHP core, doctrine, laravel
JS/TS @indexnowkit/core, next, prisma (soon)
Python indexnowkit, indexnowkit-django (soon)

Design rationale: docs/spec. Changelog: CHANGELOG.md.

MIT. IndexNow is a trademark of its owner; this project is independent and not affiliated with Microsoft, Yandex or indexnow.org.