Skip to content

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)

Packagist Downloads CI Conformance PHPStan PHP License

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-After back-off and a retry through your queue, 403 escalation.
  • check and explain in 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 created events. Their URLs are resolved in postFlush, once identifiers are assigned.
  • Updates are classified per rule from UnitOfWork::getEntityChangeSet(). A rule whose when turned false becomes a deletion and is resolved immediately in onFlush, while the old state is still live; a rule whose when turned true becomes a creation; otherwise it is an update, filtered by the rule's fields. 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.tags resubmits 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() and rollBack() — nesting level 0, identically in DBAL 3 and 4 (Middleware\IndexNowConnection / IndexNowConnectionV3, picked by IndexNowDriver at 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 inner rollBack()) drops the URLs staged inside it; the outer COMMIT delivers 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, and Connection::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 ... SELECT never reach postFlush.
  • 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, on indexnowkit/core). In a Symfony application use indexnowkit/symfony-bundle, which wires this package.
  • Minimal complete snippet (every use included):
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:check and bin/console indexnow:explain 'App\\Entity\\Post' 1; standalone, $indexNow->explain($post) returns the resolved URLs with the rule that produced each.
  • Pitfalls:
  • dispatch: auto exists in Symfony (auto | messenger | sync | none) and Yii2 (auto | queue | sync | none), not in Laravel (queue | sync | none).
  • Locales: router.locales in Laravel, router.languages in Yii2, framework.enabled_locales in 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 in url:.
  • A string in when: is an accessor read as truthy (published, isPublished). A status string needs Equals: when: new Equals('status', 'published') (IndexNowKit\Attribute\Param\Equals).
  • Manual submission is submitEntity() in Symfony, submitModel() in Laravel, submitRecord() in Yii2; the commands are indexnow: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 facade IndexNowKit\Laravel\Facades\IndexNowKit and the core service IndexNowKit\IndexNowKit (inject by type). Yii2 exposes the core through Yii::$app->indexnow->kit().
  • Outside production a configured key with dry_run unset makes check fail (a staging copy would submit real URLs): set dry_run: true there, or dry_run: false explicitly when it submits on purpose.
  • Unknown configuration keys are warned about at boot (typos such as debounce.per_urls); the key list is Config::OPTIONS plus 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.