Yii2 IndexNow extension — indexnowkit/yii2¶
Tell search engines about new, changed and deleted pages the moment an ActiveRecord row is committed. One attribute on the model, one component, done.
Русская версия · Issues and pull requests: github.com/indexnowkit/php (the php-* repositories are read-only splits)
Who gets notified¶
Yandex, Bing (and DuckDuckGo via Bing), Naver, Seznam, Yep, Internet Archive, Amazon — every engine in the IndexNow registry. One request to the shared endpoint reaches all of them; name engines explicitly only to reach a single one.
Google: no. Google does not support IndexNow; this package will not pretend otherwise.
Notification, not indexing. IndexNow tells an engine that a URL changed; whether and when the page is crawled and indexed is the engine's decision. See the result in Bing Webmaster Tools (IndexNow Insights) and Yandex.Webmaster (Indexing → Reindex pages); a useful metric is the share of submitted URLs in the index after a few days. Deleted pages: answer 410 (gone for good) or 404 (temporarily); for a move answer 301 and submit both URLs; a soft-404 or a redirect to the home page does harm. Bing's URL Submission API and Google's Indexing API are different protocols and not covered here.
Why this over X¶
Most IndexNow packages are a thin HTTP client: you collect the URLs, you call it, you read the answer. This family does the part that goes wrong in practice:
- Declared on the model (
#[IndexNow]) and submitted from the ORM hooks — no controller code to forget. - After the commit, not on flush: a rolled-back transaction announces nothing.
- Debounce (10 minutes per URL, shared through your cache), batches of up to 10 000 URLs, one key per host from env.
- Answers handled: 202 (key pending), 422, 429 with
Retry-Afterback-off and a retry through your queue, 403 escalation. checkbefore the first submission says what is wrong (key file, engines, queue, cache, environment);explainsays why a URL was or was not sent.- One core under the Symfony, Laravel, Yii2 and Doctrine adapters with a shared conformance suite: the same behaviour everywhere, documented once.
Install¶
composer require indexnowkit/yii2 symfony/http-client nyholm/psr7 # any PSR-18 client + PSR-17 factories work
composer require indexnowkit/sitemap # optional: the indexnow/sitemap command
// config/web.php and config/console.php
'bootstrap' => ['indexnow'], // registers the console controller and the key file route
'components' => [
'indexnow' => [
'class' => \IndexNowKit\Yii2\IndexNowComponent::class,
'options' => [
'key' => getenv('INDEXNOW_KEY'),
'base_url' => 'https://www.example.com', // used by console commands and queue workers
'dry_run' => YII_ENV_DEV, // dev/staging: log the request, send nothing (check fails when this is unset outside production)
],
],
],
php yii indexnow/key-generate --write-env # writes INDEXNOW_KEY=… to .env (or prints the key)
php yii indexnow/check # options, key file reachable, queue, cache, URL rules
Yii2 does not read .env by itself: export the variable (export INDEXNOW_KEY=…), put it in the web server or
container environment, or load the file with vlucas/phpdotenv before config/*.php runs — getenv('INDEXNOW_KEY')
returns false until one of these is done, and check says no key configured. In yii2-app-basic, config/web.php
and config/console.php are independent: configure the indexnow component and urlManager (pretty URLs,
rules) in both, or check, explain and submit-record see a different setup than the web application. Pretty URLs
(urlManager.enablePrettyUrl) are required for the key file route /<key>.txt. The package needs a PSR-18 client
(symfony/http-client + nyholm/psr7 as above, or Guzzle); it discovers one, or takes the component/class named in
http.client.
Declare what has a public page¶
#[IndexNow] is repeatable: one attribute per family of public URLs. IndexNowBehavior registers the hooks. Save the
example as models/Post.php under namespace app\models; — it reads the columns slug, title, body, published,
amp (the AMP page exists while it is true) and category_id; Category is a record of your own with its own
#[IndexNow] rule (drop the via: 'category' line if you have none).
use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults};
use IndexNowKit\Yii2\ActiveRecord\IndexNowBehavior;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;
#[IndexNowDefaults(when: 'published', fields: ['slug', 'title', 'body', 'published'])]
#[IndexNow(route: 'post/view', params: ['slug' => 'slug'])]
#[IndexNow(route: 'post/amp', params: ['slug' => 'slug'], when: 'amp')]
#[IndexNow(via: 'category')] // a changed post also refreshes its category page
#[IndexNow(urls: ['/'])] // and the homepage
final class Post extends ActiveRecord
{
public static function tableName(): string
{
return 'posts';
}
public function init(): void
{
parent::init();
$this->loadDefaultValues(); // `published` has a database default: make it visible before the first save
}
public function behaviors(): array
{
return [IndexNowBehavior::class];
}
public function getCategory(): ActiveQuery
{
return $this->hasOne(Category::class, ['id' => 'category_id']);
}
}
route / params | a Yii route (controller/action) and param => attribute, method, "self", dotted.path (self = the primary key) |
| resolver | a UrlResolverInterface class or component id for anything custom |
| via | a relation (or dotted path) whose pages are resubmitted |
| url / urls | a method returning the URL(s), or literal URLs |
| when / whenFields | bool attribute or method; drafts are skipped and published → draft is sent as a deletion |
| fields | for updates, submit only when one of these attributes changed |
| events, locales, host, name | subset of events; current/all/list (router.languages); another host; stable rule id |
Accessors read ActiveRecord attributes and relations (category.slug) and fall back to methods. A when column
that only has a database default is null on a fresh record: call $this->loadDefaultValues() in init() or set
the attribute before save().
Classes you cannot annotate: 'active_record' => ['models' => [Product::class]] in the options, or
Yii::$app->indexnow->observe(Product::class, [new IndexNow(...)]) at runtime.
Full model, typed parameters, inheritance and the semantics table: core attribute reference.
How it works¶
- URLs are resolved in the ActiveRecord event, while the old state is live (
changedAttributesonafterUpdate, the row and its relations inbeforeDelete). A renamed page announces its old URL as deleted. - Outside a transaction they go to the request collector right away. Inside one, Yii2 gives no savepoint events, so
they are held with a verifier and re-read by primary key when the transaction commits: a change the row does
not show (an inner
beginTransaction()that rolled back) is dropped with every URL it produced. A rollback drops everything. OneSELECTper changed record, only inside explicit transactions. Details: docs/commit-safety.md. - Everything collected during one request is sent after the response (
Response::EVENT_AFTER_SEND), in one batch; console commands flush when they end, queue workers after every job. dispatch: auto(default) pushes aSubmitUrlsJobto thequeuecomponent whenyiisoft/yii2-queueis configured (429/5xx re-pushed with the delay ofretry.*,Retry-Afterhonoured), else sends synchronously. Details: docs/queue.md.- Nothing thrown from a rule, a resolver or the HTTP layer reaches your application: it is logged under the
indexnowcategory, the save succeeds. An invalid configuration disables IndexNow with onecriticalline;php yii indexnow/checkprints the exact error.
Commands¶
| Command | Options |
|---|---|
indexnow/check |
--live real probe · --host= one host · --probe-url= page for the probe |
indexnow/submit <urls...> |
--force ignore debounce · --dry-run · --json |
indexnow/submit-record <class> [ids...] |
--event= · --limit= · --explain · --force · --dry-run · --json |
indexnow/explain <class> <id> |
--event= — rules, when, URLs, key, debounce; sends nothing |
indexnow/sitemap [sitemap] |
--changed-since="1 day" · --allow-foreign-hosts · --force · --dry-run · --json |
indexnow/key-generate |
--length · --alphanumeric · --write-env[=FILE] · --force rotate |
<class> is an FQCN or a short name under app\models. Ids are space- or comma-separated.
Sitemaps¶
composer require indexnowkit/sitemap # optional: the indexnow/sitemap command
indexnow/sitemap with no argument reads sitemap.url, else <base_url>/sitemap.xml; a local path works too.
Without the package everything else works unchanged: indexnow/sitemap says indexnowkit/sitemap is not
installed: composer require indexnowkit/sitemap and exits 1, indexnow/check prints sitemap: not installed (…),
a sitemap block in the options is ignored, sitemapConfig() / sitemapSource() throw a LogicException with
the same sentence. Nothing is logged about it.
Configuration and docs¶
Every option, its default and what it does: docs/configuration.md. Commit safety: docs/commit-safety.md. Replacing pieces, custom resolvers, checks: docs/extending.md. Queue, retries, failures: docs/queue.md. Several hosts, www and apex, languages: docs/multi-domain.md. Testing your integration: docs/testing.md.
Operations¶
- Production checklist
— key and base URL,
checkin the deploy pipeline,strict_hosts, a shared debounce store, a monitored queue, staging that cannot submit, the three lines to alert on. - Monitoring rules and the Sentry filter, deleted pages, what not to submit.
- Multi-domain: hosts, www and apex, languages · queue · commit safety · troubleshooting.
Debugging¶
php yii indexnow/check validates the options, fetches the key file and reports how submissions are wired (queue,
cache, pretty URLs, ActiveRecord hooks, sitemap spool); php yii indexnow/explain 'app\models\Post' 1 shows the
rules, guards and URLs of one record without sending anything; the indexnow log category at debug tells why a
URL was or was not submitted. Symptoms and fixes: docs/troubleshooting.md.
Limitations¶
updateAll(),deleteAll(),updateAttributes(),updateCounters()fire no events (conformance A13): callYii::$app->indexnow->submitRecords(Post::find()->where(...)->all())orphp yii indexnow/submit-recordafterwards.link()/unlink()write the junction row with a plain command, no event on the owner: save the owner with a bumped timestamp afterwards ($post->updated_at = time(); $post->save(false)), or callsubmitRecord($post).- The sync driver of
yii2-queueignores the delay between attempts: 429/5xx attempts run back-to-back (development only,checkwarns). - Without pretty URLs the key file cannot be routed: enable them, or serve
/<key>.txtas a static file and setkey_file.enabled: false.
Compatibility¶
Public API: the options tree, command names and options, IndexNowComponent methods and properties,
ActiveRecord\IndexNowBehavior, Queue\SubmitUrlsJob. The core's rules apply:
bc.md; what this package itself keeps stable: docs/bc.md. Before 1.0 a minor version may break; every
break is listed under "Changed" in CHANGELOG.md. Yii 2.0.45+, PHP 8.2–8.5.
Notes for AI assistants¶
- Composer package
indexnowkit/yii2(Yii 2.0.45+, onindexnowkit/core); thesitemapcommand needsindexnowkit/sitemap. Configuration: theindexnowapplication component (optionsarray),'bootstrap' => ['indexnow']. - Minimal complete snippet (every
useincluded):
use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults};
use IndexNowKit\Yii2\ActiveRecord\IndexNowBehavior;
#[IndexNowDefaults(when: 'published', fields: ['slug', 'title', 'published'])]
#[IndexNow(route: 'post/view', params: ['slug' => 'slug'])]
#[IndexNow(urls: ['/'])]
final class Post extends ActiveRecord { public function behaviors(): array { return [IndexNowBehavior::class]; } }
- Verify:
php yii indexnow/check(exit 1 on any error;--strictfails on warnings too,--jsonfor machines),php yii indexnow/config --json(the effective configuration, keys masked: paste it into a bug report),php yii indexnow/explain 'app\\models\\Post' 1(why a URL was or was not produced),php yii indexnow/submit-record 'app\\models\\Post' 1 --dry-run. - Pitfalls:
dispatch: autoexists in Symfony (auto|messenger|sync|none) and Yii2 (auto|queue|sync|none), not in Laravel (queue|sync|none).- Locales:
router.localesin Laravel,router.languagesin Yii2,framework.enabled_localesin Symfony;locales: 'all'on a rule uses that list. url:names an accessor (method or property) that returns the URL;urls:is a list of literal URLs. Never put a literal inurl:.- A string in
when:is an accessor read as truthy (published,isPublished). A status string needsEquals:when: new Equals('status', 'published')(IndexNowKit\Attribute\Param\Equals). - Manual submission is
submitEntity()in Symfony,submitModel()in Laravel,submitRecord()in Yii2; the commands areindexnow:submit-entity,indexnow:submit-model,indexnow/submit-record. Bulk queries (update(),DB::table(),updateAll()) fire no hooks: submit afterwards with those. - Laravel has two classes called
IndexNowKit: the facadeIndexNowKit\Laravel\Facades\IndexNowKitand the core serviceIndexNowKit\IndexNowKit(inject by type). Yii2 exposes the core throughYii::$app->indexnow->kit(). - Outside production a configured key with
dry_rununset makescheckfail (a staging copy would submit real URLs): setdry_run: truethere, ordry_run: falseexplicitly when it submits on purpose. - Unknown configuration keys are warned about at boot (typos such as debounce.per_urls); the key list is
Config::OPTIONSplus the adapter's own keys.
Other frameworks¶
| PHP | core, symfony-bundle, doctrine, laravel |
| JS/TS | @indexnowkit/core, next, prisma (soon) |
| Python | indexnowkit, indexnowkit-django (soon) |
MIT. IndexNow is a trademark of its owner; this project is independent and not affiliated with Microsoft, Yandex or indexnow.org.