Коли я почала активніше публікувати й оновлювати сторінки ICanUp, мені не хотілося щоразу вручну повідомляти Bing про новий URL.
Sitemap у проєкті вже був, але я хотіла додати ще один механізм: після реальної зміни public content Laravel сам формує потрібні URL і асинхронно відправляє їх через IndexNow.
У результаті задача виявилася цікавішою за простий HTTP request. Потрібно було врахувати локалізації, draft і scheduled content, зміну slug, category tree, старі URL після перейменування і навіть content blocks.
Що саме я хотіла автоматизувати
Мені не був потрібен механізм відправити весь сайт у Bing.
Я хотіла іншу поведінку: якщо я публікую Post, змінюю його slug, переношу в іншу Category, редагую вже опублікований content або знімаю сторінку з публікації - IndexNow повинен отримати тільки ті URL, яких ця зміна реально стосується.
При цьому draft, admin pages, preview URLs і внутрішні сторінки взагалі не повинні потрапляти в цей flow.
Home
Posts
Pages
Categories
Ukrainian URLs
English URLs
Old URLs після зміни slug
Related public URLs, якщо зміна впливає на них
Почала я з окремого IndexNow configuration
Я не хотіла розкидати endpoint, timeout і feature flag по application code, тому винесла все в окремий `config/indexnow.php`.
INDEXNOW_ENABLED=falseINDEXNOW_KEY=INDEXNOW_ENDPOINT=https://api.indexnow.org/indexnowINDEXNOW_KEY_LOCATION=INDEXNOW_TIMEOUT=5Для verification key я зробила окремий public endpoint
IndexNow потребує verification key, доступний із сайту.
Замість окремого вручну створеного static file я зробила Laravel action і маршрут
/indexnow-key.txt.
Endpoint віддає configured key, а якщо key не заданий - повертає 404.
Окремо я залишила його доступним навіть тоді, коли submissions тимчасово disabled. Verification endpoint і відправка URL - для мене дві різні відповідальності.
GET /indexnow-key.txtIndexNow key
→ потрібно окремо підтримувати файл
/indexnow-key.txt
→ Laravel action
→ configured verification key
HTTP integration я сховала за окремим client
Мені не хотілося, щоб PostService або PageService знали щось про формат IndexNow HTTP payload.
Тому application залежить від IndexNowClientInterface, а конкретний HTTP implementation знаходиться в infrastructure layer.
Це дало просту межу: application передає список URL, а client уже знає endpoint, key, host, timeout і 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" ]}Відправку URL я винесла в queue
Публікація Post або Page не повинна чекати відповіді зовнішнього IndexNow endpoint.
Тому фактичний HTTP request виконує SubmitIndexNowUrlsJob.
Job перевіряє, чи IndexNow enabled, і тільки тоді передає URLs у client.
Якщо integration disabled, content flow продовжує працювати без зовнішнього request.
Content change ↓IndexNowNotificationService ↓SubmitIndexNowUrlsJob ↓IndexNowClientInterface ↓IndexNow HTTP APIЗовнішній indexing API не повинен вирішувати, чи вдалося мені зберегти Post.
Notification я запускаю тільки після успішного commit
Це був для мене важливий момент.
URL я збираю під час domain operation, але queue notification запускаю через DB::afterCommit(). Якщо transaction не завершилася успішно, немає сенсу повідомляти пошукову систему про стан, якого в database насправді немає.
Той самий pattern я використала для Posts, Pages і Categories.
$indexNowUrls = $this->indexNowUrlService->postTargets($post); DB::afterCommit(function () use ($post, $indexNowUrls): void { $this->indexNowNotificationService->notify($indexNowUrls); $this->indexNowPublicationScheduler->schedulePost($post);});Найцікавішою частиною став не HTTP client, а збір URL
Спочатку може здаватися, що для Post достатньо відправити його власний URL.
Але в реальному блозі одна зміна може впливати на кілька public pages. Опублікований Post з’являється на Home, належить до Category, має UK та EN URL.
Тому я винесла цю логіку в IndexNowUrlService, а не залишила її всередині admin services.
Post→ localized post URLs→ Home→ related Category URLs Page→ localized public Page URLs Category→ localized Category URLs→ Home→ ancestors→ descendantsЛокалізацію я не дублювала всередині IndexNow
ICanUp уже мав окрему логіку формування localized public URLs, тому я використала існуючий HreflangService.
IndexNow не повинен сам вирішувати, як виглядає український або англійський route. Він отримує готові public URLs і лише прибирає duplicates.
Це продовжує той самий підхід, про який я писала в статті про logical route names і localized URLs.
https://example.com/posts/my-posthttps://example.com/en/posts/my-postДля Home я окремо прибрала `x-default` duplicate: IndexNow потрібні реальні URL, а не повтор одного й того самого target під SEO label.
Draft content я навмисно не відправляю
IndexNowUrlService спочатку перевіряє public visibility.
Draft Post не має IndexNow targets. Неопублікована Page теж. Inactive Category також повертає порожній список.
Це дозволило зробити notification layer простішим: якщо public URLs немає, job просто нічого не має відправляти.
Draft
Post saved
→ public targets: []
→ nothing submitted
Published
Post saved
→ post + home + category targets
→ queued IndexNow submission
При зміні slug потрібен не тільки новий URL
Один із неочевидних моментів - rename.
Перед update я збираю старі public targets. Після збереження - нові. Потім об’єдную два набори і прибираю duplicates.
Так IndexNow отримує інформацію не лише про появу нового URL, а й про попередній URL, стан якого теж змінився.
$oldIndexNowUrls = $this->indexNowUrlService->postTargets($post); $post = $this->savePost($post, $request); $newIndexNowUrls = $this->indexNowUrlService->postTargets($post); $indexNowUrls = array_values(array_unique([ ...$oldIndexNowUrls, ...$newIndexNowUrls,]));Зміна Post може означати зміну Category page
Ще один практичний edge case з’явився при перенесенні Post між Categories.
Змінюється не тільки URL самого Post. Старий category listing втрачає Post, а новий - отримує його.
Тому при move я відправляю old і new category targets разом із рештою affected URLs.
Old Category ↓Post moves ↓New Category Notify:- old category URL- new category URL- post URL- related public targetsДля Categories scope виявився ще ширшим
Categories у ICanUp мають tree structure.
Коли active Category переміщується, зміна може вплинути на ancestor і descendant pages. Саме тому categoryTargets() включає не лише поточну Category, а й Home, ancestors та descendants.
При move я так само збираю targets до і після зміни.
Current Category
Home
Ancestor Categories
Descendant Categories
Old tree targets
New tree targets
Я не обмежила IndexNow лише create та update форми
У ICanUp основний content Post і Page редагується окремими content blocks.
Тому зміна published Post у block editor теж повинна повідомити IndexNow, навіть якщо основна модель Post не змінювала slug або status.
Для draft content така сама дія не створює 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 і delete теж є зміною для пошукової системи
Перед unpublish або delete я зберігаю попередні public targets і відправляю саме їх після успішної transaction.
Scheduled publication потребувала окремого flow
Future published Post створює ще одну проблему: на момент save він ще не public, тому відправляти URL одразу неправильно.
Для Posts і Pages я додала окремий IndexNowPublicationScheduler. Він ставить delayed unique job на час publication.
Коли job запускається, він ще раз перевіряє актуальний стан entity. Якщо publication time змінився або content більше не public, stale job нічого не відправляє.
Save future Post ↓schedule unique delayed job ↓publication time ↓check current Post state again ↓public? yes → build current targets → submit no → do nothingУсі URL проходять deduplication
Один URL може потрапити в target set кількома шляхами, тому перед submission я фільтрую порожні values, прибираю duplicates і нормалізую список.
$urls = array_filter( $urls, static fn (mixed $url): bool => is_string($url) && $url !== '',) |> array_unique(...) |> array_values(...);Найбільше tests було не для HTTP request
Сам IndexNow HTTP client перевірити було нескладно.
Набагато важливішими для мене стали behavior tests: що відбувається з draft, publish, scheduled publication, slug change, category move, delete, content block update і localization.
У фінальній IndexNow-групі було:
57 tests і 179 assertions
Job queued / disabled 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 move
Category tree move
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)У результаті flow залишився досить простим
Post / Page / Category mutation ↓ URL collector ↓ after DB commit ↓ notification service ↓ queued job ↓ HTTP client ↓ IndexNowМені подобається, що domain services не знають деталей IndexNow API.
Вони лише знають, які public URLs змінилися, і передають їх у SEO integration після commit. Localization залишається в existing URL generation layer, HTTP transport - в infrastructure, а scheduled content має свій delayed flow.
Це вийшло трохи більше коду, ніж один Http::post(), зате behavior тепер відповідає реальній структурі блогу.
Що я залишила собі на майбутнє
IndexNow доповнює sitemap, а не замінює його.
Відправляти потрібно affected public URLs, а не просто URL зміненої entity.
Old URL важливий так само, як new URL.
SEO notifications краще запускати після DB commit.
Draft content не повинен потрапляти в indexing flow.
Scheduled content потрібно перевіряти повторно в момент фактичної публікації.
Localization logic не варто дублювати всередині IndexNow integration.
Наступний SEO edge case
Після IndexNow я зіткнулася з іншою неочевидною деталлю: `sitemap lastmod` не завжди можна брати просто з `entity.updated_at`, особливо коли реальна зміна public page живе в translation table. Це буде наступна стаття з цієї SEO-серії.
Висновок
На початку задача звучала дуже просто: після публікації сторінки відправити її URL у Bing (https://www.bing.com).
Але реальна integration швидко показала, що головне питання зовсім не в HTTP request. Потрібно правильно визначити, які public URLs насправді змінилися.
Для мене IndexNow у Laravel у результаті став не API wrapper, а невеликим event-like SEO flow: зібрати affected URLs, дочекатися commit, відправити їх через queue і ще раз перевірити state для scheduled content.
Найважливішою частиною IndexNow integration виявилося не як відправити URL, а які саме URL справді змінилися.



