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.
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`.
INDEXNOW_ENABLED=falseINDEXNOW_KEY=INDEXNOW_ENDPOINT=https://api.indexnow.org/indexnowINDEXNOW_KEY_LOCATION=INDEXNOW_TIMEOUT=5I 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.
GET /indexnow-key.txtIndexNow 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.
{ "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 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.
Content change ↓IndexNowNotificationService ↓SubmitIndexNowUrlsJob ↓IndexNowClientInterface ↓IndexNow HTTP APIAn 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.
$indexNowUrls = $this->indexNowUrlService->postTargets($post); DB::afterCommit(function () use ($post, $indexNowUrls): void { $this->indexNowNotificationService->notify($indexNowUrls); $this->indexNowPublicationScheduler->schedulePost($post);});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.
Post→ localized post URLs→ Home→ related Category URLs Page→ localized public Page URLs Category→ localized Category URLs→ Home→ ancestors→ descendantsI 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.
https://example.com/posts/my-posthttps://example.com/en/posts/my-postFor 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 submittedPublished
Post saved
→ post + home + category targets
→ queued IndexNow submissionA 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.
$oldIndexNowUrls = $this->indexNowUrlService->postTargets($post); $post = $this->savePost($post, $request); $newIndexNowUrls = $this->indexNowUrlService->postTargets($post); $indexNowUrls = array_values(array_unique([ ...$oldIndexNowUrls, ...$newIndexNowUrls,]));Moving a Post Also Changes Category Pages
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.
Old Category ↓Post moves ↓New Category Notify:- old category URL- new category URL- post URL- related public targetsCategories 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 jobPublished content block update
Content changed
→ public URL changed semantically
→ IndexNow notification queuedUnpublish 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.
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.
Save future Post ↓schedule unique delayed job ↓publication time ↓check current Post state again ↓public? yes → build current targets → submit no → do nothingEvery 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.
$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
dartisan test --group=indexnowTests: 57 passed (179 assertions)The Final Flow Stayed Fairly Simple
Post / Page / Category mutation ↓ URL collector ↓ after DB commit ↓ notification service ↓ queued job ↓ HTTP client ↓ IndexNowI 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?”.



