0% прочитано

Laravel IndexNow: автоматична відправка нових і оновлених сторінок у Bing

Я не хотіла вручну повідомляти Bing про кожну нову або оновлену сторінку ICanUp. Тому інтегрувала IndexNow у Laravel: key endpoint, HTTP client, queue job, генерацію URL для постів, сторінок і категорій, локалізації, deduplication та tests.

1 вересня 2026 р. 7 хв читанняЛаравель

Коли я почала активніше публікувати й оновлювати сторінки ICanUp, мені не хотілося щоразу вручну повідомляти Bing про новий URL.

Sitemap у проєкті вже був, але я хотіла додати ще один механізм: після реальної зміни public content Laravel сам формує потрібні URL і асинхронно відправляє їх через IndexNow.

У результаті задача виявилася цікавішою за простий HTTP request. Потрібно було врахувати локалізації, draft і scheduled content, зміну slug, category tree, старі URL після перейменування і навіть content blocks.

IndexNow у мене не замінив sitemap. Я залишила обидва механізми: sitemap описує доступні public URLs, а IndexNow повідомляє про конкретні зміни одразу після них.

Що саме я хотіла автоматизувати

Мені не був потрібен механізм відправити весь сайт у 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`.

ENV - налаштування IndexNow
INDEXNOW_ENABLED=falseINDEXNOW_KEY=INDEXNOW_ENDPOINT=https://api.indexnow.org/indexnowINDEXNOW_KEY_LOCATION=INDEXNOW_TIMEOUT=5
Я залишила IndexNow disabled за замовчуванням. Так сама наявність integration code ще не починає робити зовнішні requests у новому environment.

Для verification key я зробила окремий public endpoint

IndexNow потребує verification key, доступний із сайту.

Замість окремого вручну створеного static file я зробила Laravel action і маршрут

/indexnow-key.txt.

Endpoint віддає configured key, а якщо key не заданий - повертає 404.

Окремо я залишила його доступним навіть тоді, коли submissions тимчасово disabled. Verification endpoint і відправка URL - для мене дві різні відповідальності.

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

IndexNow 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.

JSON - Спрощений 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"    ]}
У публічному прикладі я не показую реальний IndexNow key. Він не потрібен для розуміння integration flow.

Відправку URL я винесла в queue

Публікація Post або Page не повинна чекати відповіді зовнішнього IndexNow endpoint.

Тому фактичний HTTP request виконує SubmitIndexNowUrlsJob.

Job перевіряє, чи IndexNow enabled, і тільки тоді передає URLs у client.

Якщо integration disabled, content flow продовжує працювати без зовнішнього request.

IndexNow submission flow
Content changeIndexNowNotificationServiceSubmitIndexNowUrlsJobIndexNowClientInterfaceIndexNow HTTP API

Зовнішній indexing API не повинен вирішувати, чи вдалося мені зберегти Post.

Notification я запускаю тільки після успішного commit

Це був для мене важливий момент.

URL я збираю під час domain operation, але queue notification запускаю через DB::afterCommit(). Якщо transaction не завершилася успішно, немає сенсу повідомляти пошукову систему про стан, якого в database насправді немає.

Той самий pattern я використала для Posts, Pages і 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);});
Я також перевірила failure isolation: якщо queue dispatch для IndexNow падає, створення Post, Page або Category не повинно через це втрачатися.

Найцікавішою частиною став не HTTP client, а збір URL

Спочатку може здаватися, що для Post достатньо відправити його власний URL.

Але в реальному блозі одна зміна може впливати на кілька public pages. Опублікований Post з’являється на Home, належить до Category, має UK та EN URL.

Тому я винесла цю логіку в IndexNowUrlService, а не залишила її всередині 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

Локалізацію я не дублювала всередині IndexNow

ICanUp уже мав окрему логіку формування localized public URLs, тому я використала існуючий HreflangService.

IndexNow не повинен сам вирішувати, як виглядає український або англійський route. Він отримує готові public URLs і лише прибирає duplicates.

Це продовжує той самий підхід, про який я писала в статті про logical route names і localized URLs.

Localized IndexNow targets
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, стан якого теж змінився.

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,]));
Якщо URL може змінитися під час update, старий public target краще зафіксувати до mutation. Після save відновити його вже може бути складно або неможливо.

Ще один практичний edge case з’явився при перенесенні Post між Categories.

Змінюється не тільки URL самого Post. Старий category listing втрачає Post, а новий - отримує його.

Тому при move я відправляю old і new category targets разом із рештою affected URLs.

Post move - affected public targets
Old CategoryPost movesNew 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.

IndexNow потрібен не тільки коли URL з’явився. Старий URL після unpublish, delete або rename теж змінив свій стан і тому залишається важливим target.

Scheduled publication потребувала окремого flow

Future published Post створює ще одну проблему: на момент save він ще не public, тому відправляти URL одразу неправильно.

Для Posts і Pages я додала окремий IndexNowPublicationScheduler. Він ставить delayed unique job на час publication.

Коли job запускається, він ще раз перевіряє актуальний стан entity. Якщо publication time змінився або content більше не public, stale job нічого не відправляє.

Scheduled IndexNow flow
Save future Postschedule unique delayed jobpublication timecheck current Post state againpublic?  yes → build current targets → submit  no  → do nothing
Для delayed SEO jobs я не довіряю стану entity на момент scheduling. Перед зовнішнім request job ще раз перевіряє current state.

Усі URL проходять deduplication

Один URL може потрапити в target set кількома шляхами, тому перед submission я фільтрую порожні values, прибираю duplicates і нормалізую список.

PHP - Deduplicated URL list
$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

BASH - Запуск IndexNow test group
dartisan test --group=indexnow
Результат - IndexNow tests
Tests: 57 passed (179 assertions)
Після integration tests також пройшов PHPStan без помилок. Для мене це було підтвердженням, що IndexNow залишився окремим SEO mechanism і не розмив contracts Post, Page та Category modules.

У результаті flow залишився досить простим

Final IndexNow architecture
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 справді змінилися.