indexnowkit/doctrine — commit-safe IndexNow for Doctrine ORM¶
Listens to onFlush / postFlush, resolves the URLs of entities that declare #[IndexNow] rules, and hands them
over only after the outermost transaction really committed, using a DBAL driver middleware. Rolled-back flushes
submit nothing. Deletions are resolved before the row disappears.
Doctrine ORM 2.19+ and 3.x, DBAL 3.x and 4.x, PHP 8.2+.
Русская версия · Issues and pull requests: github.com/indexnowkit/php (the php-* repositories are read-only splits)
Symfony users: take indexnowkit/symfony-bundle — it wires all of this, adds the router
bridge, the commands and the profiler panel. This package is for Doctrine without Symfony.
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. checkandexplainin the Symfony bundle say what is wrong before the first submission and 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/doctrine
Standalone wiring¶
use Doctrine\DBAL\DriverManager;
use Doctrine\ORM\{EntityManager, ORMSetup};
use IndexNowKit\{Config, IndexNowKit};
use IndexNowKit\Doctrine\IndexNowDoctrine;
use IndexNowKit\Url\{ArrayResolverLocator, AttributeUrlResolver};
$indexNow = IndexNowKit::create(Config::fromEnv(), logger: $logger);
$resolver = new AttributeUrlResolver(
$indexNow->attributes,
router: null, // no framework router: see "Routes" below
locator: new ArrayResolverLocator([
'post_url' => fn (Post $post): string => '/posts/' . $post->slug, // #[IndexNow(resolver: 'post_url')]
]),
logger: $logger,
);
$wiring = new IndexNowDoctrine($indexNow, $resolver, $logger, autoFlush: true);
$ormConfiguration = ORMSetup::createAttributeMetadataConfiguration([__DIR__ . '/src/Entity'], isDevMode: false);
$wiring->registerMiddleware($ormConfiguration); // BEFORE DriverManager::getConnection()
$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => __DIR__ . '/var/app.db'], $ormConfiguration);
$entityManager = new EntityManager($connection, $ormConfiguration);
$wiring->registerListener($entityManager);
registerMiddleware() must run before the connection is created, because DBAL middlewares wrap the driver at
connect time. In a typical bootstrap that means: build the ORM Configuration, call registerMiddleware(), then
create the EntityManager, then call registerListener().
$autoFlush: true submits as soon as the URLs are handed over, which is what a script or a CLI process wants. Pass
false and call $indexNow->flush() yourself at the end of the unit of work when you control the request cycle.
IndexNowDoctrine exposes the three pieces it builds — $wiring->staging, $wiring->listener,
$wiring->middleware — so a container can register them individually instead.
Declaring pages¶
The #[IndexNow] attribute comes from the core and is repeatable: one rule per family of public URLs.
use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults};
#[ORM\Entity]
#[IndexNowDefaults(when: 'isPublished', fields: ['slug', 'title', 'body', 'published'])]
#[IndexNow(resolver: 'post_url')]
#[IndexNow(via: 'category')] // a changed post also refreshes its category page
#[IndexNow(urls: ['/'])] // and the homepage
class Post
{
#[ORM\Column]
private bool $published = false;
public function isPublished(): bool { return $this->published; }
}
Full model — sources, typed parameters, when / whenFields / fields / events / locales / host,
inheritance and the semantics table — is in the core's
attribute reference.
Routes¶
#[IndexNow(route: ...)] needs a RouteUrlResolverInterface bridge to a framework router. Standalone Doctrine has
none, so a rule using route: fails at resolution time with "no router bridge is configured" (logged, never
thrown into your flush). Use url:, urls: or resolver: instead, or implement the two-method
RouteUrlResolverInterface for your own router and pass it as the router: argument above.
What the listener does¶
In onFlush, every scheduled insertion, update, deletion and changed collection is classified per rule through
the core's ObjectChangeHandler:
- Insertions produce
createdevents. Their URLs are resolved inpostFlush, once identifiers are assigned. - Updates are classified per rule from
UnitOfWork::getEntityChangeSet(). A rule whosewhenturned false becomes a deletion and is resolved immediately inonFlush, while the old state is still live; a rule whosewhenturned true becomes a creation; otherwise it is an update, filtered by the rule'sfields. One entity can therefore produce an update for one page and a deletion for another in the same flush. - Changed to-many associations are not part of the owner's change set, so a scheduled collection update or
deletion re-classifies its owner with the association's field name as the changed field. Changing
post.tagsresubmits the post's pages. - Deletions are resolved in
onFlush, before the row disappears. A rule that does not apply — a draft that was never public — submits nothing.
In postFlush the deferred rules are resolved, every URL is logged at debug with the rule that produced it
(indexnow: App\Entity\Post#post_amp (updated) -> https://example.com/amp/hello), and the batch is handed off.
Nothing here throws into your application. An invalid attribute, an unreadable when accessor or a failing resolver
is logged on the indexnow channel and yields no URLs.
Renamed pages¶
When a field a route parameter reads changes — the slug, the category the path goes through — the old URL now
answers 404. On an update the listener resolves the rule against the previous values of the change set and
announces those URLs as deleted, next to the new URLs as updated, in the same flush (ObjectChangeHandler::renamed(),
scenario A21). Route rules only; the old page must have been public (when true before the change); a field the URL
depends on that cannot be written back (readonly, uninitialized) skips the old URL with a debug line. Nothing in
this path throws into flush().
Commit safety¶
postFlush runs before the outer COMMIT whenever flush() is wrapped in wrapInTransaction() or a manual
transaction, and Doctrine has no after-commit event. So:
- if the connection has an open transaction, the URLs are staged against its native connection object;
- the DBAL driver middleware sees the real
commit()androllBack()— nesting level 0, identically in DBAL 3 and 4 (Middleware\IndexNowConnection/IndexNowConnectionV3, picked byIndexNowDriverat connect time) — and either releases the staged URLs or discards them; - a
commit()that itself throws discards them too, so a pooled connection never delivers them later; - a nested transaction rolled back to its savepoint (
ROLLBACK TO SAVEPOINT, what DBAL issues for an innerrollBack()) drops the URLs staged inside it; the outerCOMMITdelivers the rest; - outside a transaction the URLs are handed over immediately.
If the driver exposes no native connection object, the listener logs a warning and submits inside the open transaction rather than losing the URLs.
Limitations¶
- DQL and QueryBuilder bulk
UPDATE/DELETE, andConnection::executeStatement(), bypass the unit of work and are not detected. Submit those URLs with$indexNow->submit(). route:needs a router bridge (see above).- Entities inserted through
INSERT ... SELECTnever reachpostFlush. - Attributes are not read from interfaces or traits: PHP does not inherit class attributes through them, and Doctrine mapping behaves the same way.
Compatibility with other listeners¶
Register the listener after anything that computes values the URLs depend on. With Gedmo Sluggable the slug is
written in onFlush, so the IndexNow listener must run later; the Symfony bundle uses priority -100 for exactly
this reason.
Compatibility¶
Public API of this package: the classes named in the changelog and the README, their constructor parameter names (pass optional arguments by name), and the DBAL middleware classes. The core's rules apply, including the "may grow" interfaces: 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 with the migration.
Notes for AI assistants¶
- Composer package
indexnowkit/doctrine(Doctrine ORM 2.19+/3, DBAL 3/4, onindexnowkit/core). In a Symfony application useindexnowkit/symfony-bundle, which wires this package. - Minimal complete snippet (every
useincluded):
use Doctrine\ORM\Mapping as ORM;
use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults};
#[ORM\Entity]
#[IndexNowDefaults(when: 'isPublished')]
#[IndexNow(route: 'post_show', params: ['slug' => 'slug'])] // a Symfony route, or resolver: 'post_url' without a router
class Post { /* ORM columns, isPublished() */ }
- Verify: with the bundle,
bin/console indexnow:checkandbin/console indexnow:explain 'App\\Entity\\Post' 1; standalone,$indexNow->explain($post)returns the resolved URLs with the rule that produced each. - 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.
Documentation¶
| Attribute reference | core/docs/attribute-reference.md |
| Configuration | core/docs/configuration.md |
| Operations and logging | core/docs/operations.md |
| Testing | core/docs/testing.md |
| Writing your own adapter | core/docs/adapters.md |
| Design rationale | docs/spec |
Changelog: CHANGELOG.md. Versioning: SemVer; before 1.0 minor versions may break.
MIT. IndexNow is a trademark of its owner; this project is independent and not affiliated with Microsoft, Yandex or indexnow.org.