slimad/indexnow-laravel

Laravel integration for IndexNow: service provider, configuration, cache/database backed queues, queued job, Artisan commands, scheduler binding and an Eloquent trait for submitting URL changes to Bing, Yandex, Seznam, Naver and Yep.

1.0 2026-08-31 12:36 UTC

This package is auto-updated.

Last update: 2026-08-31 17:48:12 UTC


README

CI Security Latest stable version PHP version License: MIT

Laravel integration for IndexNow: tell Bing, Yandex, Seznam, Naver and Yep about new, updated and deleted URLs the moment your content changes, instead of waiting for the next crawl.

It wraps the framework-agnostic slimad/indexnow package and adds everything Laravel-shaped: configuration, container bindings, a cache- or database-backed pending queue, a queued job, four Artisan commands, a scheduler binding, events, a key-file route and an Eloquent trait.

How it works

URLs are queued as they change and submitted in batches. Nothing talks to a search engine during a web request.

Model saved / event listener / Artisan
                │
                ▼
        IndexNow::submit()                  ← cheap: one write to the pending store
                │
                ▼
   pending store (cache | database)
                │
   scheduler ───┤
                ▼
       indexnow:flush  ──►  SubmitQueuedUrlsJob  ──►  Laravel HTTP client  ──►  endpoints

URLs stay in the store until every configured engine has accepted them, so a failed submission is retried on the next run instead of being lost.

Requirements

  • PHP 8.2+
  • Laravel 12 or 13

Installation

composer require slimad/indexnow-laravel

The service provider and the IndexNow facade are registered automatically.

Publish the configuration:

php artisan vendor:publish --tag=indexnow-config

Generate a key and see exactly what to put in .env:

php artisan indexnow:key
INDEXNOW_HOST=example.com
INDEXNOW_KEY=8f14e45fceea167a5a36dedd4bea2543

Finally, make the key readable as text/plain at https://example.com/{key}.txt. Either drop the file into public/, or let the package serve it:

INDEXNOW_KEY_ROUTE_ENABLED=true

Check everything at once:

php artisan indexnow:status
+------------------------+----------------------------------------------------------+
| Setting                | Value                                                    |
+------------------------+----------------------------------------------------------+
| Enabled                | yes                                                      |
| Store                  | cache                                                    |
| Pending URLs           | 12                                                       |
| Queue                  | yes                                                      |
| Host                   | example.com                                              |
| Engine: indexnow       | https://api.indexnow.org/indexnow                        |
| Key location: indexnow | https://example.com/8f14e45fceea167a5a36dedd4bea2543.txt  |
+------------------------+----------------------------------------------------------+

Queueing URLs

From anywhere

use SlimAD\IndexNow\Laravel\Facades\IndexNow;

IndexNow::submit('https://example.com/products/42');

IndexNow::submit(
    'https://example.com/products/42',
    'https://example.com/categories/shoes',
);

IndexNow::submitMany($urls);   // any iterable of strings or SlimAD\IndexNow\ValueObject\Url

submit() returns the number of distinct URLs that were queued, and throws SlimAD\IndexNow\Exception\InvalidUrlException if a value is not an absolute http/https URL.

From an Eloquent model

Implement ProvidesIndexNowUrls and use the SubmitsToIndexNow trait; the model's URLs are queued on every saved and deleted event.

use Illuminate\Database\Eloquent\Model;
use SlimAD\IndexNow\Laravel\Concerns\SubmitsToIndexNow;
use SlimAD\IndexNow\Laravel\Contracts\ProvidesIndexNowUrls;

final class Product extends Model implements ProvidesIndexNowUrls
{
    use SubmitsToIndexNow;

    public function indexNowUrls(): iterable
    {
        if (! $this->is_published) {
            return [];          // drafts are not worth a crawl
        }

        return [
            route('products.show', $this),
            route('categories.show', $this->category),
        ];
    }
}

Return an empty iterable to skip a model. $model->submitToIndexNow() queues the same URLs on demand.

From the command line

php artisan indexnow:submit https://example.com/a https://example.com/b
php artisan indexnow:submit https://example.com/a --flush

Submitting

Scheduled (recommended)

INDEXNOW_SCHEDULE_ENABLED=true
INDEXNOW_SCHEDULE_CRON="*/5 * * * *"

The package registers indexnow:flush on Laravel's scheduler with withoutOverlapping(). You need a running scheduler (php artisan schedule:work, or the usual schedule:run cron entry).

Manually

php artisan indexnow:flush          # dispatches a queued job when queueing is on
php artisan indexnow:flush --sync   # always submits inline

Programmatically

$result = IndexNow::flush();        // submits inline, returns a SubmitJobResult

$result->submittedUrls;             // how many URLs were in the batch
$result->discardedUrls;             // queued URLs that did not belong to the configured host
$result->failures;                  // list<SubmitFailedException>, one per failing engine
$result->isSuccess();

IndexNow::dispatchFlush();          // hands it to a queue worker, false when queueing is off
IndexNow::pending();                // how many URLs are waiting
IndexNow::clear();                  // empty the queue without submitting

Configuration

Every setting has an environment variable, so the published config file is optional.

Key Env Default What it does
enabled INDEXNOW_ENABLED true Master switch. When off, nothing is queued and nothing is submitted.
host INDEXNOW_HOST Your canonical host. IndexNow only accepts URLs that belong to it.
key INDEXNOW_KEY The IndexNow key, 8–128 ASCII letters, digits and dashes.
key_location INDEXNOW_KEY_LOCATION https://{host}/{key}.txt Where the key file lives.
engines api.indexnow.org Endpoints to notify; each may override key and key_location.
store INDEXNOW_STORE cache Which pending store to use: cache, database or array.
batch_size INDEXNOW_BATCH_SIZE 10000 URLs per request; the protocol caps this at 10 000.
http.timeout INDEXNOW_HTTP_TIMEOUT 10 Request timeout in seconds.
http.connect_timeout INDEXNOW_HTTP_CONNECT_TIMEOUT 5 Connection timeout in seconds.
http.retries INDEXNOW_HTTP_RETRIES 3 Attempts per request.
http.retry_delay INDEXNOW_HTTP_RETRY_DELAY 250 Milliseconds between attempts.
http.user_agent INDEXNOW_HTTP_USER_AGENT slimad/indexnow-laravel Identify your own application.
queue.enabled INDEXNOW_QUEUE_ENABLED true Submit from a queue worker instead of inline.
queue.connection INDEXNOW_QUEUE_CONNECTION app default Queue connection for the job.
queue.queue INDEXNOW_QUEUE app default Queue name for the job.
queue.tries INDEXNOW_QUEUE_TRIES 3 Job attempts.
queue.backoff [60, 300, 900] Seconds between job attempts.
schedule.enabled INDEXNOW_SCHEDULE_ENABLED false Register indexnow:flush on the scheduler.
schedule.cron INDEXNOW_SCHEDULE_CRON */5 * * * * How often to flush.
key_route.enabled INDEXNOW_KEY_ROUTE_ENABLED false Serve the key at /{key}.txt.
key_route.middleware [] Middleware for that route.

Several engines

Participating engines forward submissions to each other, so the single api.indexnow.org endpoint is normally enough. Configure more only when you want to notify an engine independently — for example because it was verified with a different key:

'engines' => [
    'indexnow' => ['endpoint' => 'https://api.indexnow.org/indexnow'],
    'yandex' => [
        'endpoint' => 'https://yandex.com/indexnow',
        'key' => env('INDEXNOW_YANDEX_KEY'),
    ],
],

Pending URL stores

Driver Survives a cache flush Needs a migration Good for
cache (default) no no most applications
database yes yes high-value pages you cannot afford to lose
array no no tests

The cache store keeps everything in one entry and takes a cache lock around every mutation when the underlying store supports one (Redis, Memcached, DynamoDB, database), so concurrent requests do not overwrite each other.

For the database store:

php artisan vendor:publish --tag=indexnow-migrations
php artisan migrate
INDEXNOW_STORE=database

URLs are deduplicated on a SHA-256 hash rather than on the URL column, so long URLs cannot outgrow the unique index.

Anything implementing SlimAD\IndexNow\Store\UrlStore works — bind your own in a service provider:

$this->app->singleton(\SlimAD\IndexNow\Store\UrlStore::class, fn () => new RedisStreamUrlStore(...));

Events

Event When
SlimAD\IndexNow\Laravel\Events\UrlsQueued URLs were added to the pending store ($event->urls)
SlimAD\IndexNow\Laravel\Events\SubmissionFailed one engine rejected the batch or was unreachable ($event->failure)
SlimAD\IndexNow\Laravel\Events\SubmissionCompleted a flush finished, successful or not ($event->result)
Event::listen(SubmissionFailed::class, function (SubmissionFailed $event): void {
    Log::warning('IndexNow rejected a submission', [
        'endpoint' => $event->failure->endpoint,
        'status' => $event->failure->statusCode,
    ]);
});

Testing your own application

The package submits through Laravel's HTTP client, so Http::fake() works as usual:

Http::fake(['https://api.indexnow.org/*' => Http::response('', 202)]);

IndexNow::submit('https://example.com/a');
IndexNow::flush();

Http::assertSent(fn ($request) => $request->data()['urlList'] === ['https://example.com/a']);

Use the array store and turn queueing off to keep tests synchronous:

config()->set('indexnow.store', 'array');
config()->set('indexnow.queue.enabled', false);

Or turn the package off entirely for a test:

config()->set('indexnow.enabled', false);

Troubleshooting

IndexNow is not configured: "indexnow.host" is empty. — set INDEXNOW_HOST. Run php artisan indexnow:status to see what the package resolved.

URLs are reported as discarded. — IndexNow only accepts URLs on the submitted host, and matching is exact: example.com does not cover www.example.com. Set INDEXNOW_HOST to the host your canonical URLs actually use.

rejected the submission with status 403 — the endpoint could not read your key at key_location. Open the URL in a browser: it has to return the key as text/plain.

Nothing is submitted. — check indexnow.enabled, that the scheduler is running, and that a queue worker is processing the configured connection. php artisan indexnow:flush --sync submits immediately and prints what happened.

Quality gates

  • PHP 8.2, 8.3 and 8.4 × Laravel 12 and 13, plus a lowest-dependency run
  • 100% line coverage, enforced in CI
  • Mutation testing with Infection
  • Larastan level 8, no baseline
  • Laravel Pint for the coding standard
  • composer audit and Dependabot
composer ci          # cs + phpstan + test + infection
composer test
composer cs:fix

See CONTRIBUTING.md.

License

MIT — see LICENSE.