DEV Community

Cover image for Instant search engine indexing in Laravel with LaraIndexNow
Eduardo Lázaro
Eduardo Lázaro

Posted on

Instant search engine indexing in Laravel with LaraIndexNow

You publish a post and then you wait. Your sitemap says here is everything I have, come back whenever, and the crawler comes back whenever. The page might there invisible for days.

IndexNow flips that around. It is a small protocol: you publish a key file on your domain, then POST the URLs that changed. Bing, Yandex, Seznam, Naver and Yep consume it, and one submission to the shared endpoint reaches all of them. Google does not participate, so this complements your sitemap, it does not replace it. If that alone rules it out for you, stop reading here.

Laraindexnow wires the protocol into Eloquent. You describe which records deserve a URL and under what conditions, and the package works out when to submit. This post walks through the whole thing.

How to Install

composer require edulazaro/laraindexnow
php artisan vendor:publish --tag=indexnow-config
php artisan indexnow:key --file
Enter fullscreen mode Exit fullscreen mode

The last command generates a key, writes INDEXNOW_KEY to your .env the way key:generate writes APP_KEY, and drops the key file into public/. That file is the whole authentication story: if you can publish at https://yourdomain.com/{key}.txt, the domain is yours. There is no account to open.

The package also serves that URL through a route, so the file is optional. Generate it anyway: the web server hands a real file over without booting the framework, so it keeps answering while php artisan down returns a 503 for every route.

Then confirm it is reachable before you rely on it:

php artisan indexnow:verify
Enter fullscreen mode Exit fullscreen mode

An unreachable key file is behind almost every rejection from the endpoint, and the endpoint itself tells you nothing useful about which half failed.

Tracking a model

Register what you want tracked in a service provider:

use EduLazaro\IndexNow\Facades\IndexNow;

IndexNow::track(Post::class)
->url('blog.show')
->when('published');
Enter fullscreen mode Exit fullscreen mode

That is the whole integration. No trait, no interface, nothing added to the model.

url() takes a route name and resolves it with the record bound to it, which covers most models. Anything that already looks like a URL is left alone, and a closure handles the rest:

->url('blog.show') // route('blog.show', $post)
->url('/about') // taken literally
->url(fn (Post $post) => route('blog.show', $post->slug))
Enter fullscreen mode Exit fullscreen mode

There is also affects(), for the other URLs a write leaves out of date. Publishing a post changes the post page, but it also changes the index and the category listing:

->affects(['blog.index', fn (Post $post) => route('blog.category', $post->category)])
Enter fullscreen mode Exit fullscreen mode

Conditions are the whole API

An attribute named on its own has to hold something true, and whenNot() is the opposite. Truth is read the way PHP reads it, so 1, "1" and true all pass while 0, "0", null and "" do not. A tinyint(1) column with no boolean cast comes back from MySQL as 1, and the same attribute is true when you just assigned it in PHP. Both have to read the same way, or your conditions work on a fresh record and not on a reloaded one.

->when('published') // holds something true
->whenNot('spam') // holds something false
->when('status', 'published') // holds that value
->when('comment_count', '>', 5) // compared
->whenIn('status', ['published', 'featured'])
->when(fn (Post $post) => $post->published_at?->isPast())
Enter fullscreen mode Exit fullscreen mode

A counter reads nicely as a flag, so when('comment_count') is "has at least one".

Conditions on one registration combine with AND. Every one of them has to hold.

What actually triggers a submission

The package asks one question on every write. Did this record belong in the index before it, and does it belong after?

Before After Submitted
no no no
no yes yes, it entered the index
yes yes only if a content attribute changed
yes no yes, it left the index

That last row is the one people forget. A record leaving the index matters as much as one entering it: the URL now 404s or turns noindex, and the engines should be told to go and look. The same goes for a deleted record, and for a changed slug, where both the old URL and the new one go out.

The third row is where you keep the noise down. Not every write is a content change, and some applications rewrite half a table on a schedule:

->ignoring(['view_count', 'stats_cache', 'search_index', 'last_seen_at'])
Enter fullscreen mode Exit fullscreen mode

A scheduled command that recomputes cached counters across a large table is the case that bites: without that list every row it touches counts as a content change, and one run becomes thousands of submissions. With it, the run is silent and a real edit still goes out.

Transitions, when the state is not enough

Sometimes the interesting thing is not what the record holds but what just happened to it:

->whenBecame('status', 'published') // it just took that value
->whenLeft('comment_count', 0) // it stopped holding zero
->whenIncrements('comment_count') // a number went up
->whenChanged('title', 'body', 'cover') // these changed, whatever the values
Enter fullscreen mode Exit fullscreen mode

whenChanged() is the precise way to say what counts as content, and it is the inverse of ignoring(): a whitelist instead of a blacklist. If forgetting one column would flood the endpoint, prefer the whitelist.

Several ways in

Here is the case that breaks a single condition set. Say a page is worth indexing once it is approved and has at least one comment. Either half can be the one that arrives last: approval can come first and the comment later, or the other way round, and no single AND describes both.

Call track() again for the same model and you get a second registration. Registrations combine with OR: a write is submitted when any of them wants it.

IndexNow::track(Post::class)
->url('blog.show')
->whenLeft('comment_count', 0) // a comment arrived, already approved
->when('approved');

IndexNow::track(Post::class)
->url('blog.show')
->whenBecame('approved', true) // approval arrived, comments already there
->when('comment_count');
Enter fullscreen mode Exit fullscreen mode

Writing that as one registration does not work: the two transitions would have to happen in the same write, which never does. The model is observed once however many registrations it has, and a URL two of them resolve is submitted once.

Submitting once, and remembering it

sets() writes an attribute on the record after its URL has been handed over. Put the same attribute in the conditions and the record drops out of the registration the moment it is written. Submitted once, ever, however many times you save it afterwards:

IndexNow::track(Post::class)
  ->url('blog.show')
  ->whenNot('index_now_sent')
  ->when('published')
  ->sets('index_now_sent');
Enter fullscreen mode Exit fullscreen mode

Two things to know. The write is quiet, because a normal save would fire another event and the record, having just left the conditions, would go out a second time. And it means handed over, not accepted by a search engine: the buffer carries URLs and nothing else, so by the time a submission succeeds or fails there is no record left to point back at. Listen for SubmissionFailed if a failure has to undo it.

A flag like this is the wrong tool when a page should be resubmitted after an edit, since a written record never comes back. For that, declare the edit as its own registration with whenChanged().

The gap you need to know about

Eloquent events do not fire for query builder writes. This publishes a hundred posts and submits nothing:

Post::where('scheduled_for', '<=', now())->update(['published' => true]);
Enter fullscreen mode Exit fullscreen mode

No model is instantiated, so no observer runs. That is Eloquent, not this package, and the answer is indexnow:sync:

php artisan indexnow:sync Post --limit=1000 --dry-run
Enter fullscreen mode Exit fullscreen mode

It walks the table, evaluates the same conditions the observer uses, and submits what matches. There is no second copy of the rule to keep in step. Pair it with sets() and each pass only picks up what is left, which makes a backfill of a large table resumable. A dry run never writes.

The other direction is seeding and importing, where you want the writes but not the noise:

IndexNow::withoutSubmissions(function () {
    Post::factory()->count(50_000)->create();
});
Enter fullscreen mode Exit fullscreen mode

Buffering, queueing and deduplication

Model events do not send HTTP requests. They push URLs into a cache backed buffer and schedule one delayed job to drain it, so saving a hundred rows produces one request rather than a hundred.

The same URL is not submitted twice within the deduplication window. Editing a post five times in ten minutes is one submission. Keep that window short, an hour by default: it is an anti-bounce measure, not a record of what is already indexed, and a long window swallows real edits. Publish at 09:00 with a 24 hour window and the correction you make at 15:00 never goes out.

Retries live on the job. A 429 is released with the endpoint's own Retry-After, and a 5xx backs off at 1, 5 and 15 minutes. A rejected key or a malformed URL is never retried, because retrying will not fix it.

Testing

$fake = IndexNow::fake();

$post->update(['status' => 'published']);

$fake->assertSubmitted(route('blog.show', $post->slug));
$fake->assertSubmittedCount(1);
$fake->assertNothingSubmitted();
Enter fullscreen mode Exit fullscreen mode

fake() swaps the transport and bypasses the queue, so assertions work without a worker.

Not from your laptop

Submissions only happen in the environments listed in the config, production by default. That guard is what stops a development machine from asking search engines to crawl URLs that resolve on localhost, and it is the first thing to check when nothing is going out.

For a staging machine that should exercise the whole path without talking to the endpoint, set driver to null and log to log. To stop submissions in production without deploying, set INDEXNOW_ENABLED=false and clear the config cache.

Wrapping up

That is the surface. track() to register, url() to name the page, conditions to say when it counts, a second track() when there is more than one way in, and indexnow:sync for everything Eloquent never saw.

👉 Package on Packagist.
👉 Source on GitHub.

Written against version 1.4. If you try it and something is missing, open an issue.

Top comments (0)