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.
Package info
github.com/indexnowkit/php-symfony-bundle
Type:symfony-bundle
pkg:composer/indexnowkit/symfony-bundle
Requires
- php: ^8.2
- ext-json: *
- indexnowkit/core: ^0.3
- symfony/config: ^6.4 || ^7.0
- symfony/console: ^6.4 || ^7.0
- symfony/dependency-injection: ^6.4 || ^7.0
- symfony/event-dispatcher: ^6.4 || ^7.0
- symfony/framework-bundle: ^6.4 || ^7.0
- symfony/http-foundation: ^6.4 || ^7.0
- symfony/http-kernel: ^6.4 || ^7.0
- symfony/routing: ^6.4 || ^7.0
Requires (Dev)
- doctrine/dbal: ^3.8 || ^4.0
- doctrine/doctrine-bundle: ^2.13 || ^3.0
- doctrine/orm: ^2.19 || ^3.0
- doctrine/persistence: ^3.0 || ^4.0
- indexnowkit/doctrine: ^0.2
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.1
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.5
- symfony/browser-kit: ^6.4 || ^7.0
- symfony/http-client: ^6.4 || ^7.0
- symfony/messenger: ^6.4 || ^7.0
- symfony/twig-bundle: ^6.4 || ^7.0
- symfony/web-profiler-bundle: ^6.4 || ^7.0
- symfony/yaml: ^6.4 || ^7.0
Suggests
- doctrine/doctrine-bundle: Required with indexnowkit/doctrine (^2.13 || ^3.0)
- indexnowkit/doctrine: Automatic submission when Doctrine entities change (^0.2, together with doctrine/doctrine-bundle)
- nyholm/psr7: PSR-17 factories for symfony/http-client
- symfony/http-client: HTTP client for outgoing requests (wrapped as PSR-18 automatically)
- symfony/messenger: Async dispatch with retries (dispatch: messenger, ^6.4 || ^7.0)
- symfony/web-profiler-bundle: IndexNow panel in the Web Profiler
Provides
None
Conflicts
None
Replaces
None
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.
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/postFlushand 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: autouses Messenger when a transport is configured, otherwise sends synchronously after the response.syncalways sends on terminate.nonecollects and never sends, for applications that drain the collector themselves.- The same URL is not re-sent within 10 minutes (
debounce.per_url, stored incache.app), batches are split at 10 000 URLs, hosts are grouped,202is a success,403means the key file is wrong. - Failures are logged on the
indexnowMonolog channel and never break your request.http.timeout(10 s) andthrottle.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.
bin/console indexnow:explain App\Entity\Post 42walks the whole decision path for one entity — rules, event subscription,whenguard,fieldsfilter, resolved URLs, normalization, host and key, key file, debounce — and sends nothing.- 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.
- The
indexnowMonolog channel carries everything. Set it todebugwhile 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/DELETEbypass the unit of work: useindexnow:submitor$indexNow->submit(). - Sub-domains are separate hosts: give each its own key with the
hostsmap, and setstrict_hosts: trueso a host you did not configure is skipped rather than announced under the default key. dispatch: syncdepends onkernel.terminateactually firing. An earlyexit(), 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 preferdispatch: messenger.- Long-running custom commands should call
$indexNow->flush()periodically instead of accumulating URLs for the whole process lifetime. - Outside production (
production_environments, defaultprod/production), a missingINDEXNOW_KEYswitchesdry_runon 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
readonlyproperty only gets the new URL (logged atdebug).
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.