Implementing IndexNow Protocol in PHP for Instant Indexing

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5168

    #1

    Implementing IndexNow Protocol in PHP for Instant Indexing

    If you publish content frequently and want search engines to discover it faster, IndexNow is one of the easiest wins available. In this tutorial, I'll walk through a complete PHP implementation of the IndexNow protocol, based on what I built for DailyWatch, a video discovery platform that publishes hundreds of new pages daily.


    What Is IndexNow?

    IndexNow is an open protocol that lets you notify participating search engines (Bing, Yandex, Seznam, Naver) the moment a URL is created, updated, or deleted. Instead of waiting for crawlers to find your changes, you push the information to them.


    Step 1: Generate Your API Key

    The key can be any string of hexadecimal characters (a-f, 0-9), between 8 and 128 characters:






    // Generate a random IndexNow API key
    $apiKey = bin2hex(random_bytes(16));
    echo $apiKey; // e.g., "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"







    Step 2: Place the Key File

    Create a text file at your domain root containing only your API key:






    function createKeyFile(string $apiKey, string $webRoot): void {
    $keyFilePath = rtrim($webRoot, '/') . '/' . $apiKey . '.txt';
    file_put_contents($keyFilePath, $apiKey);

    // Verify it's accessible
    echo "Key file created at: {$keyFilePath}\n";
    echo "Verify at: https://yoursite.com/{$apiKey}.txt\n";
    }







    This file proves to search engines that you own the domain.


    Step 3: Build the Submission Client

    Here's a complete, production-ready IndexNow client class:






    class IndexNowClient {
    private const ENDPOINT = 'https://api.indexnow.org/indexnow';
    private const MAX_URLS_PER_BATCH = 10000;
    private const TIMEOUT = 10;

    public function __construct(
    private readonly string $host,
    private readonly string $apiKey,
    private readonly string $keyLocation = '',
    ) {}

    /**
    * Submit a single URL
    */
    public function submit(string $url): IndexNowResult {
    return $this->submitBatch([$url]);
    }

    /**
    * Submit multiple URLs in a single request
    */
    public function submitBatch(array $urls): IndexNowResult {
    if (empty($urls)) {
    return new IndexNowResult(success: true, submitted: 0, message: 'No URLs to submit');
    }

    // Chunk if exceeding max batch size
    if (count($urls) > self::MAX_URLS_PER_BATCH) {
    return $this->submitChunked($urls);
    }

    $payload = [
    'host' => $this->host,
    'key' => $this->apiKey,
    'urlList' => array_values($urls),
    ];

    if ($this->keyLocation) {
    $payload['keyLocation'] = $this->keyLocation;
    }

    $ch = curl_init(self::ENDPOINT);
    curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_HTTPHEADER => [
    'Content-Type: application/json; charset=utf-8',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => self::TIMEOUT,
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $error = curl_error($ch);
    curl_close($ch);

    if ($error) {
    return new IndexNowResult(
    success: false,
    submitted: 0,
    message: "cURL error: {$error}"
    );
    }

    return new IndexNowResult(
    success: $httpCode >= 200 && $httpCode 300,
    submitted: count($urls),
    message: "HTTP {$httpCode}",
    httpCode: $httpCode
    );
    }

    private function submitChunked(array $urls): IndexNowResult {
    $chunks = array_chunk($urls, self::MAX_URLS_PER_BATCH);
    $totalSubmitted = 0;
    $errors = [];

    foreach ($chunks as $chunk) {
    $result = $this->submitBatch($chunk);
    if ($result->success) {
    $totalSubmitted += $result->submitted;
    } else {
    $errors[] = $result->message;
    }
    usleep(100000); // 100ms delay between batches
    }

    return new IndexNowResult(
    success: empty($errors),
    submitted: $totalSubmitted,
    message: empty($errors) ? 'All batches submitted' : implode('; ', $errors)
    );
    }
    }

    readonly class IndexNowResult {
    public function __construct(
    public bool $success,
    public int $submitted,
    public string $message,
    public int $httpCode = 0,
    ) {}
    }







    Step 4: Integrate with Your Content Pipeline

    At DailyWatch, we call IndexNow at the end of each cron fetch cycle:






    // After fetching and storing new videos
    $newVideoIds = $db->getNewVideoIdsSinceLastFetch();

    if (!empty($newVideoIds)) {
    $urls = array_map(
    fn(string $id) => "https://dailywatch.video/watch/{$id}",
    $newVideoIds
    );

    $client = new IndexNowClient(
    host: 'dailywatch.video',
    apiKey: $config['indexnow_key']
    );

    $result = $client->submitBatch($urls);

    echo "IndexNow: submitted {$result->submitted} URLs, "
    . "status: {$result->message}\n";
    }







    HTTP Response Codes

    200 URLs submitted successfully
    202 URLs accepted, key validation pending
    400 Invalid request
    403 Key not valid or not matching
    422 URLs don't belong to the host
    429 Too many requests (rate limited)


    Results

    After implementing IndexNow on dailywatch.video, Bing indexing time dropped from 3-5 days to under 1 hour. The implementation took about 2 hours total, making it one of the highest-ROI SEO improvements available.


    The full source code is available on GitHub. If you have questions about the implementation, drop them in the comments.




    More...
Working...