0% прочитано

Laravel IndexNow: Automatically Submit New and Updated Pages to Bing

I did not want to notify Bing manually every time I published or updated a page on ICanUp. I integrated IndexNow into Laravel with a key endpoint, HTTP client, queued submission job, localized URL generation for posts, pages and categories, deduplication, and tests.

1 вересня 2026 р. 7 хв читанняLaravel

As I started publishing and updating more content on ICanUp, I did not want to manually notify Bing every time a public URL changed.

The project already had a sitemap, but I wanted another mechanism as well: after a real public-content change, Laravel should collect the affected URLs and submit them asynchronously through IndexNow.

The task turned out to be much more interesting than a single HTTP request. I had to account for localization, drafts, scheduled publication, slug changes, category trees, old URLs after renames, and even content-block updates.

IndexNow did not replace my sitemap. I kept both: the sitemap describes the public URL set, while IndexNow reports specific changes as they happen.

What I Actually Wanted to Automate

I was not looking for a submit the whole site to Bing feature.

I wanted a different behavior: when I publish a Post, change its slug, move it to another Category, edit already published content, or unpublish a page, IndexNow should receive only the URLs actually affected by that change.

Drafts, admin pages, preview URLs, and internal application pages should never enter this flow.

  • Home

  • Posts

  • Pages

  • Categories

  • Ukrainian URLs

  • English URLs

  • Old URLs after a slug change

  • Related public URLs when a change affects them

I Started With a Separate IndexNow Configuration

I did not want the endpoint, timeout, and feature flag scattered through application code, so I moved them into a dedicated `config/indexnow.php`.

ENV - IndexNow settings
INDEXNOW_ENABLED=falseINDEXNOW_KEY=INDEXNOW_ENDPOINT=https://api.indexnow.org/indexnowINDEXNOW_KEY_LOCATION=INDEXNOW_TIMEOUT=5
I kept IndexNow disabled by default. Having the integration code available should not automatically start external requests in a new environment.

I Added a Separate Public Verification-Key Endpoint

IndexNow needs a verification key that is reachable from the site.

Instead of maintaining a separate static file manually, I added a Laravel action and exposed

/indexnow-key.txt

It returns the configured key, or a 404 when no key is configured.

I also kept this endpoint available when submissions themselves are disabled. Verification and URL submission are separate responsibilities in my implementation.

Public IndexNow key endpoint
GET /indexnow-key.txt
До

IndexNow key

→ separate file to maintain

Після

/indexnow-key.txt

→ Laravel action

→ configured verification key

I Hid the HTTP Integration Behind a Client

I did not want PostService or PageService to know anything about the IndexNow HTTP payload.

The application therefore depends on IndexNowClientInterface, while the concrete HTTP implementation lives in the infrastructure layer.

The boundary is simple: the application provides URLs, and the client owns the endpoint, key, host, timeout, and batch request.

JSON - Simplified IndexNow batch payload
{    "host": "example.com",    "key": "<INDEXNOW_KEY>",    "keyLocation": "https://example.com/indexnow-key.txt",    "urlList": [        "https://example.com/posts/example-post",        "https://example.com/en/posts/example-post"    ]}
I do not expose the real IndexNow key in a public example. Its value is not relevant to understanding the integration flow.

I Moved URL Submission to the Queue

Publishing a Post or Page should not wait for an external IndexNow response.

The actual HTTP request is therefore handled by SubmitIndexNowUrlsJob. The job first checks whether IndexNow is enabled and only then passes the URLs to the client.

When the integration is disabled, the normal content flow continues without an external request.

IndexNow submission flow
Content changeIndexNowNotificationServiceSubmitIndexNowUrlsJobIndexNowClientInterfaceIndexNow HTTP API

An external indexing API should not decide whether my Post was successfully saved.

I Notify IndexNow Only After a Successful Commit

This was an important detail for me.

I collect the URLs during the domain operation, but the queue notification runs through DB::afterCommit(). If the transaction does not commit successfully, there is no reason to notify a search engine about a state that never actually reached the database.

I used the same pattern for Posts, Pages, and Categories.

PHP - Post IndexNow notification after commit
$indexNowUrls = $this->indexNowUrlService->postTargets($post); DB::afterCommit(function () use ($post, $indexNowUrls): void {    $this->indexNowNotificationService->notify($indexNowUrls);    $this->indexNowPublicationScheduler->schedulePost($post);});
I also tested failure isolation: if dispatching the IndexNow queue job fails, creating a Post, Page, or Category should not fail with it.

The Interesting Part Was URL Collection, Not the HTTP Client

At first, it may look as if submitting a Post means submitting only its own URL.

In a real blog, one change can affect several public pages. A published Post appears on Home, belongs to a Category, and has Ukrainian and English URLs.

That is why I moved this logic into IndexNowUrlService instead of keeping it inside the admin services.

IndexNow URL targets
Post→ localized post URLs→ Home→ related Category URLs Page→ localized public Page URLs Category→ localized Category URLs→ Home→ ancestors→ descendants

I Did Not Reimplement Localization Inside IndexNow

ICanUp already had a dedicated way to generate localized public URLs, so I reused the existing HreflangService.

IndexNow should not decide how an English or Ukrainian route is built. It receives the real public URLs and only removes duplicates.

This follows the same idea I described in my article about logical route names and localized URLs.

Localized IndexNow targets
https://example.com/posts/my-posthttps://example.com/en/posts/my-post

For Home, I explicitly removed the `x-default` duplicate: IndexNow needs real URLs, not the same target repeated under an SEO label.

I Deliberately Excluded Draft Content

IndexNowUrlService checks public visibility first.

A draft Post has no IndexNow targets. An unpublished Page has none either. An inactive Category also produces an empty list.

That keeps the notification layer simple: if there are no public URLs, there is nothing to submit.

До

Draft

Post saved
→ public targets: []
→ nothing submitted

Після

Published

Post saved
→ post + home + category targets
→ queued IndexNow submission

A Slug Change Needs More Than the New URL

One less obvious case was renaming.

Before the update, I collect the old public targets. After saving, I collect the new ones. Then I merge the two sets and remove duplicates.

This way, IndexNow receives information not only about the new URL but also about the previous URL whose state has changed.

PHP - Old + new IndexNow targets
$oldIndexNowUrls = $this->indexNowUrlService->postTargets($post); $post = $this->savePost($post, $request); $newIndexNowUrls = $this->indexNowUrlService->postTargets($post); $indexNowUrls = array_values(array_unique([    ...$oldIndexNowUrls,    ...$newIndexNowUrls,]));
If an update can change a URL, capture the old public target before the mutation. Reconstructing it after save may be difficult or impossible.

Another practical edge case appeared when moving a Post between Categories.

The Post URL is not the only thing that changes. The old category listing loses the Post, while the new one gains it.

So the notification includes both old and new category targets together with the other affected public URLs.

Post move - affected public targets
Old CategoryPost movesNew Category Notify:- old category URL- new category URL- post URL- related public targets

Categories Had an Even Wider Scope

Categories in ICanUp have a tree structure.

When an active Category moves, the change can affect ancestor and descendant pages. That is why categoryTargets() includes not only the current Category but also Home, ancestors, and descendants.

For a move, I again collect targets before and after the change.

  • Current Category

  • Home

  • Ancestor Categories

  • Descendant Categories

  • Old tree targets

  • New tree targets

I Did Not Limit IndexNow to Create and Update Forms

In ICanUp, the main Post and Page content is edited separately through content blocks.

That means changing a published Post in the block editor should still notify IndexNow even when the main Post model did not change its slug or status.

The same action on draft content does not produce a notification.

До

Draft content block update

Content changed
→ page is not public
→ no IndexNow job

Після

Published content block update

Content changed
→ public URL changed semantically
→ IndexNow notification queued

Unpublish and Delete Are Changes Too

Before unpublishing or deleting an entity, I capture its previous public targets and submit those URLs after the transaction commits.

IndexNow is not useful only when a URL appears. An old URL after unpublish, delete, or rename has changed state as well and remains an important target.

Scheduled Publication Needed Its Own Flow

A future-published Post creates another problem: at save time it is not public yet, so submitting the URL immediately would be wrong.

For Posts and Pages, I added IndexNowPublicationScheduler. It queues a delayed unique job for the publication time.

When the job eventually runs, it checks the entity again. If the publication time changed or the content is no longer public, the stale job submits nothing.

Scheduled IndexNow flow
Save future Postschedule unique delayed jobpublication timecheck current Post state againpublic?  yes → build current targets → submit  no  → do nothing
For delayed SEO jobs, I do not trust the entity state from scheduling time. The job verifies the current state again before making an external request.

Every URL List Is Deduplicated

The same URL can enter the target set through several paths, so before submission I filter empty values, remove duplicates, and normalize the list.

PHP - Deduplicated URL list
$urls = array_filter(    $urls,    static fn (mixed $url): bool => is_string($url) && $url !== '',)    |> array_unique(...)    |> array_values(...);

Most of My Tests Were Not About the HTTP Request

Testing the IndexNow HTTP client itself was straightforward.

The more important coverage was behavioral: drafts, publication, scheduled publication, slug changes, category moves, deletion, content-block updates, and localization.

The final IndexNow test group contained:

57 tests with 179 assertions

  • Queued / disabled job behavior

  • Batch HTTP payload

  • Empty URL list

  • Verification key endpoint

  • Localized Home URLs

  • Localized Post URLs

  • Localized Page URLs

  • Public visibility

  • Old + new slug targets

  • Post category moves

  • Category tree moves

  • Draft / publish / unpublish

  • Delete

  • Content-block updates

  • Scheduled Posts

  • Scheduled Pages

  • Rescheduling

  • Stale delayed jobs

  • Queue-dispatch failure isolation

BASH - Run the IndexNow test group
dartisan test --group=indexnow
Output - IndexNow tests Language: Text
Tests: 57 passed (179 assertions)
PHPStan also completed without errors. For me, that confirmed that IndexNow remained a separate SEO mechanism instead of leaking API-specific behavior across the Post, Page, and Category modules.

The Final Flow Stayed Fairly Simple

Final IndexNow architecture
Post / Page / Category mutation       URL collector      after DB commit   notification service        queued job       HTTP client         IndexNow

I like that my domain services do not know the details of the IndexNow API.

They only know which public URLs changed and hand those URLs to the SEO integration after commit. Localization stays in the existing URL-generation layer, HTTP transport stays in infrastructure, and scheduled content has its own delayed flow.

It is more code than a single Http::post(), but the behavior now matches how the blog actually works.

What I Kept for Next Time

  • IndexNow complements a sitemap; it does not replace it.

  • Submit affected public URLs, not only the URL of the modified entity.

  • An old URL matters just as much as a new one.

  • SEO notifications are safer after the database commit.

  • Draft content should stay outside the indexing flow.

  • Scheduled content should be verified again at the real publication time.

  • Do not duplicate localization logic inside the IndexNow integration.

The Next SEO Edge Case

After IndexNow, I ran into another less obvious problem: `sitemap lastmod` cannot always come directly from `entity.updated_at`, especially when the real public-page change lives in a translation table. That will be the next article in this SEO series.

Conclusion

The task sounded simple at first: submit a page URL to Bing after publication (https://www.bing.com).

The real integration quickly showed that the HTTP request was not the hard part. The important question was determining which public URLs had actually changed.

For me, IndexNow in Laravel ended up as a small event-like SEO flow: collect affected URLs, wait for the transaction to commit, submit them through the queue, and verify the current state again for scheduled content.

The hardest part of my IndexNow integration was not “how do I submit a URL?” but “which URLs actually changed?”.