PHPackages                             dancing-janissary/laravel-seo-indexing - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. dancing-janissary/laravel-seo-indexing

ActiveLibrary[API Development](/categories/api)

dancing-janissary/laravel-seo-indexing
======================================

Automatically notify Google Indexing API and IndexNow (Bing, Yandex) on Eloquent model CRUD operations.

v1.0.0(1mo ago)00MITPHPPHP ^8.2CI failing

Since Mar 19Pushed 1mo agoCompare

[ Source](https://github.com/dancing-janissary/laravel-seo-indexing)[ Packagist](https://packagist.org/packages/dancing-janissary/laravel-seo-indexing)[ RSS](/packages/dancing-janissary-laravel-seo-indexing/feed)WikiDiscussions main Synced 1mo ago

READMEChangelogDependencies (8)Versions (9)Used By (0)

laravel-seo-indexing
====================

[](#laravel-seo-indexing)

[![Tests](https://github.com/dancing-janissary/laravel-seo-indexing/actions/workflows/tests.yml/badge.svg)](https://github.com/dancing-janissary/laravel-seo-indexing/actions)[![Latest Version on Packagist](https://camo.githubusercontent.com/1a098537cb7c714e01cf5a9b523850589f29bf4801f546bf70f2e5b39a30eab4/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f64616e63696e672d6a616e6973736172792f6c61726176656c2d73656f2d696e646578696e672e737667)](https://packagist.org/packages/dancing-janissary/laravel-seo-indexing)[![PHP Version](https://camo.githubusercontent.com/c9f64f714c636ba27a3bba6dfd52f98426832db1262747efa54b212d16943651/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d253545382e322d626c7565)](https://www.php.net)[![Laravel Version](https://camo.githubusercontent.com/c5fe755dfd47b9b4bc43057da623c24960423ff2ad0739f5d874736d34b94e7a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c61726176656c2d25354531312e302d726564)](https://laravel.com)[![License: MIT](https://camo.githubusercontent.com/fdf2982b9f5d7489dcf44570e714e3a15fce6253e0cc6b5aa61a075aac2ff71b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d79656c6c6f772e737667)](LICENSE)

Automatically notify **Google Indexing API** and **IndexNow** (Bing, Yandex, Seznam, Naver) whenever your Eloquent models are created, updated, or deleted. Attach a single trait to any model and your pages are indexed without a single extra line of code.

---

Table of Contents
-----------------

[](#table-of-contents)

- [Features](#features)
- [Requirements](#requirements)
- [Installation](#installation)
- [Configuration](#configuration)
    - [Google Indexing API Setup](#google-indexing-api-setup)
    - [IndexNow Setup](#indexnow-setup)
    - [Environment Variables](#environment-variables)
- [Usage](#usage)
    - [The Indexable Trait](#the-indexable-trait)
    - [Controlling Which Pages Get Indexed](#controlling-which-pages-get-indexed)
    - [Manual Submission via Facade](#manual-submission-via-facade)
    - [Batch Submission](#batch-submission)
    - [Disabling Indexing for Bulk Operations](#disabling-indexing-for-bulk-operations)
- [Queue Setup](#queue-setup)
- [Logging &amp; Querying Submission History](#logging--querying-submission-history)
- [Architecture &amp; Design Decisions](#architecture--design-decisions)
- [API Quotas &amp; Limits](#api-quotas--limits)
- [Testing](#testing)
- [Changelog](#changelog)
- [License](#license)

---

Features
--------

[](#features)

- ✅ **Dual-engine** — submits to both Google Indexing API v3 and IndexNow in one operation
- ✅ **Zero-config CRUD hooks** — attach `Indexable` trait and forget about it
- ✅ **Queue-first** — all submissions dispatched as background jobs with automatic retry
- ✅ **Sync fallback** — disable queues entirely for simple setups or local dev
- ✅ **Per-model control** — `shouldIndex()`, `getIndexableUrl()`, and `withoutIndexing()` give fine-grained control
- ✅ **SoftDeletes aware** — handles `deleted`, `restored` events automatically
- ✅ **Full submission log** — every API call recorded to DB with engine, status, and response payload
- ✅ **Auto-pruning** — configurable log retention via Laravel's built-in model pruning
- ✅ **Deduplication** — skips re-submission if the same URL was successfully submitted recently
- ✅ **Multi-engine IndexNow** — pings Bing, Yandex, and others in a single batch request

---

Requirements
------------

[](#requirements)

RequirementVersionPHP`^8.2`Laravel`^11.0`Google Service AccountRequired for Google Indexing APIIndexNow API KeyRequired for IndexNow (Bing, Yandex, etc.)---

Installation
------------

[](#installation)

Install via Composer:

```
composer require dancing-janissary/laravel-seo-indexing
```

Laravel's auto-discovery will register the service provider and `SeoIndexing` facade automatically.

Publish the config file and migrations:

```
# Publish everything at once
php artisan vendor:publish --tag=seo-indexing

# Or selectively
php artisan vendor:publish --tag=seo-indexing-config
php artisan vendor:publish --tag=seo-indexing-migrations
```

Run the migrations:

```
php artisan migrate
```

---

Configuration
-------------

[](#configuration)

After publishing, the config file is located at `config/seo-indexing.php`.

### Google Indexing API Setup

[](#google-indexing-api-setup)

The Google Indexing API requires a **Service Account** with domain-wide delegation. Follow these steps:

1. Go to the [Google Cloud Console](https://console.cloud.google.com/) and create a project
2. Enable the **Indexing API** for your project
3. Create a **Service Account** and download the JSON credentials file
4. In [Google Search Console](https://search.google.com/search-console), add the service account email as an **Owner** of your property
5. Store the JSON key file somewhere safe on your server — **never inside your project root or git repo**

```
# Example: store outside the web root
/etc/google/my-site-indexing-credentials.json
```

Set the path in your `.env`:

```
GOOGLE_INDEXING_CREDENTIALS_PATH=/etc/google/my-site-indexing-credentials.json
```

> ⚠️ **Security:** The credentials JSON file contains a private key. Never commit it to version control. Add `*-service-account.json` and `*credentials*.json` to your `.gitignore`.

---

### IndexNow Setup

[](#indexnow-setup)

IndexNow uses a simple API key for authentication. The key must be served as a text file at your domain root so search engines can verify ownership.

1. Generate a key — must be alphanumeric, minimum 8 characters:

```
# Generate a random key
openssl rand -hex 16
```

2. Create a verification file at your domain root:

```
https://example.com/{your-key}.txt

```

The file must contain only the key itself as plain text.

3. Set your key in `.env`:

```
INDEXNOW_KEY=your_key_here
```

> **Tip:** You only need to verify with one IndexNow engine — all others accept the same key once verified. The package submits to Bing, Yandex, and `api.indexnow.org` by default.

---

### Environment Variables

[](#environment-variables)

Add these to your `.env` file:

```
# Google Indexing API
GOOGLE_INDEXING_CREDENTIALS_PATH=/absolute/path/to/credentials.json

# IndexNow
INDEXNOW_KEY=your_indexnow_key
INDEXNOW_KEY_FILE=your_indexnow_key.txt   # optional, defaults to {key}.txt

# Queue (recommended for production)
SEO_INDEXING_QUEUE_ENABLED=true
SEO_INDEXING_QUEUE_CONNECTION=redis        # or database, sqs, etc.
SEO_INDEXING_QUEUE_NAME=indexing           # dedicated queue name

# Log retention
SEO_INDEXING_LOG_RETENTION=30              # days, 0 = keep forever
```

---

### Full Config Reference

[](#full-config-reference)

```
// config/seo-indexing.php

return [

    // Enable or disable engines globally
    'engines' => [
        'google'   => true,
        'indexnow' => true,
    ],

    'google' => [
        'credentials_path' => env('GOOGLE_INDEXING_CREDENTIALS_PATH'),
        'scopes'           => ['https://www.googleapis.com/auth/indexing'],
    ],

    'indexnow' => [
        'key'      => env('INDEXNOW_KEY'),
        'key_file' => env('INDEXNOW_KEY_FILE', null),
        'host'     => env('APP_URL'),
        'engines'  => [
            'https://api.indexnow.org/indexnow',
            'https://www.bing.com/indexnow',
            'https://yandex.com/indexnow',
        ],
    ],

    'queue' => [
        'enabled'     => env('SEO_INDEXING_QUEUE_ENABLED', true),
        'connection'  => env('SEO_INDEXING_QUEUE_CONNECTION', 'default'),
        'name'        => env('SEO_INDEXING_QUEUE_NAME', 'indexing'),
        'retry_after' => 90,
    ],

    'logging' => [
        'enabled'        => true,
        'retention_days' => env('SEO_INDEXING_LOG_RETENTION', 30),
    ],

    'http' => [
        'timeout'         => 30,
        'connect_timeout' => 10,
        'retry' => [
            'times' => 3,
            'sleep' => 1000,
        ],
    ],
];
```

---

Usage
-----

[](#usage)

### The Indexable Trait

[](#the-indexable-trait)

Add the `Indexable` trait to any Eloquent model whose URLs should be submitted to search engines:

```
use DancingJanissary\SeoIndexing\Traits\Indexable;

class Page extends Model
{
    use Indexable;
}
```

That's it. The following events are now wired automatically:

Eloquent EventAction Sent`created` / `updated``URL_UPDATED``deleted``URL_DELETED``restored` *(SoftDeletes)*`URL_UPDATED`By default the URL is built from the model's `slug` attribute (or its primary key as a fallback). Override `getIndexableUrl()` to return the correct public URL for your model:

```
class Page extends Model
{
    use Indexable;

    public function getIndexableUrl(): string
    {
        return route('pages.show', $this->slug);
    }
}
```

Or set a URL prefix to use the default slug-based URL generation:

```
protected function getIndexablePrefix(): string
{
    return '/blog';
    // Produces: https://example.com/blog/{slug}
}
```

---

### Controlling Which Pages Get Indexed

[](#controlling-which-pages-get-indexed)

Override `shouldIndex()` to add conditions. Only return `true` when the page should actually be visible to search engines:

```
class Page extends Model
{
    use Indexable;

    public function shouldIndex(): bool
    {
        return parent::shouldIndex()
            && $this->status === 'published'
            && ! $this->is_private;
    }
}
```

When `shouldIndex()` returns `false`, no job is dispatched and no log entry is written.

---

### Manual Submission via Facade

[](#manual-submission-via-facade)

Use the `SeoIndexing` facade to submit URLs outside of model events — useful in controllers, commands, or observers:

```
use DancingJanissary\SeoIndexing\Facades\SeoIndexing;

// Submit a URL as updated
SeoIndexing::submit('https://example.com/page');

// Submit a URL as deleted
SeoIndexing::delete('https://example.com/old-page');
```

You can also trigger indexing directly on a model instance:

```
use DancingJanissary\SeoIndexing\SeoIndexingManager;

// Submit as updated
$page->index();

// Submit as deleted
$page->index(SeoIndexingManager::ACTION_DELETED);
```

---

### Batch Submission

[](#batch-submission)

Submit multiple URLs in one call. IndexNow supports native batch requests (up to 10,000 URLs); Google sends individual requests per URL internally.

```
SeoIndexing::submitBatch([
    'https://example.com/page-one',
    'https://example.com/page-two',
    'https://example.com/page-three',
]);

SeoIndexing::deleteBatch([
    'https://example.com/removed-one',
    'https://example.com/removed-two',
]);
```

---

### Disabling Indexing for Bulk Operations

[](#disabling-indexing-for-bulk-operations)

When importing or seeding large numbers of records, disable indexing to avoid exhausting API quotas:

```
// Option A — static disable/enable
Page::disableIndexing();

foreach ($importData as $row) {
    Page::create($row);
}

Page::enableIndexing();
```

```
// Option B — closure (re-enables automatically, even if an exception is thrown)
$page->withoutIndexing(function () use ($page) {
    $page->update(['status' => 'draft']);
});
```

---

Queue Setup
-----------

[](#queue-setup)

Queue-based submissions are strongly recommended for production. Without a queue, every model save blocks the request while waiting for Google's API response (typically 1–3 seconds).

### Why queues?

[](#why-queues)

SyncQueueRequest speedSlows down (API latency)Instant returnFailure handlingLost on timeoutAuto-retry with backoffBulk importsBlocks until all submittedNon-blockingVisibilityNone`failed_jobs` table### Dedicated queue worker

[](#dedicated-queue-worker)

Run a dedicated worker for the `indexing` queue to keep SEO submissions isolated from your main application jobs:

```
php artisan queue:work redis --queue=indexing --tries=2 --timeout=60
```

For production with Supervisor, add a separate program block:

```
[program:seo-indexing-worker]
command=php /var/www/html/artisan queue:work redis --queue=indexing --tries=2 --timeout=60
autostart=true
autorestart=true
numprocs=1
```

### Log retention (auto-pruning)

[](#log-retention-auto-pruning)

Add `model:prune` to your scheduler to automatically clean up old log entries based on the `logging.retention_days` config value:

```
// routes/console.php
Schedule::command('model:prune')->daily();
```

---

Logging &amp; Querying Submission History
-----------------------------------------

[](#logging--querying-submission-history)

Every API submission — whether successful or failed — is recorded in the `seo_indexing_logs` table. Use the `SeoIndexingLog` model to query the history:

```
use DancingJanissary\SeoIndexing\Models\SeoIndexingLog;

// All failed submissions in the last 7 days
SeoIndexingLog::failed()->recent(7)->get();

// All Google failures
SeoIndexingLog::failed()->forEngine('google')->latest()->get();

// Full history for a specific URL
SeoIndexingLog::forUrl('https://example.com/page')->latest()->get();

// All URL_DELETED submissions
SeoIndexingLog::forAction('URL_DELETED')->get();

// Successful Bing submissions
SeoIndexingLog::successful()->forEngine('indexnow:www.bing.com')->get();
```

### Log table columns

[](#log-table-columns)

ColumnDescription`url`The submitted URL`action``URL_UPDATED` or `URL_DELETED``engine``google`, `indexnow:www.bing.com`, etc.`success`Boolean result`http_status`HTTP response code from the engine`message`Error message on failure`payload`Raw JSON response from the API`indexable_type`Model class that triggered the submission`indexable_id`Model primary key`job_id`UUID linking the log entry to its queue job`queued`Whether this was dispatched via a job---

Architecture &amp; Design Decisions
-----------------------------------

[](#architecture--design-decisions)

### Dual-client architecture

[](#dual-client-architecture)

Each engine (`GoogleIndexingClient`, `IndexNowClient`) implements the same `IndexingClientContract` interface. They are bound independently in the service container, which means:

- They can be mocked independently in tests
- A failure or misconfiguration in one engine does not affect the other
- New engines can be added by implementing the contract and registering in the service provider

### One job per engine

[](#one-job-per-engine)

`SubmitUrlJob` accepts an `$engine` parameter and is dispatched separately for each enabled engine. This isolation means a Google quota error doesn't prevent Bing from receiving the submission, and each engine has its own entry in `failed_jobs` for independent retry tracking.

### Google OAuth2 token handling

[](#google-oauth2-token-handling)

The package uses `google/auth` (Google's official PHP auth library) rather than the heavier `google/apiclient`. `ServiceAccountCredentials` reads the JSON key file, signs a JWT, exchanges it for a Bearer token, and caches it for its 1-hour lifetime — all internally. This keeps the dependency footprint small while handling the full OAuth2 service account flow correctly.

### IndexNow native batching

[](#indexnow-native-batching)

Unlike Google (which requires one HTTP request per URL), IndexNow supports up to 10,000 URLs in a single POST. The `IndexNowClient::submitBatch()` method takes full advantage of this — a batch of 500 URLs becomes 3 HTTP requests (one per engine endpoint) instead of 1,500.

### Retry strategy

[](#retry-strategy)

The HTTP client retries on 5xx and connection errors but **not** on 4xx errors. A `403 Forbidden` from Google means the credentials are wrong — retrying with the same credentials will always fail and wastes quota. The job layer adds a second retry tier at a higher level for transient failures that survive HTTP retries.

### Deduplication guard

[](#deduplication-guard)

Before dispatching any job, the manager checks whether the same URL was successfully submitted to the same engine within the last 60 minutes. This prevents quota exhaustion during rapid successive saves (e.g. autosave, touch, or event chains on the same model).

### Type+ID serialization

[](#typeid-serialization)

The job stores `indexable_type` and `indexable_id` rather than the Eloquent model instance. Serializing a full model (with its relations) into a queue payload creates large payloads and risks stale data by the time the job runs. Storing the class and key keeps the payload minimal and always fetches a fresh model on execution.

---

API Quotas &amp; Limits
-----------------------

[](#api-quotas--limits)

Be aware of the following limits when planning your usage:

### Google Indexing API

[](#google-indexing-api)

LimitValueRequests per day200 per service accountRequests per minute600Supported URL typesJob posting and livestream pages only (officially)> **Note:** Google officially supports the Indexing API only for job posting and livestream structured data pages. Many developers use it for general pages successfully, but this is not officially guaranteed.

### IndexNow

[](#indexnow)

LimitValueURLs per batchUp to 10,000Daily limitNo hard limit published (10,000+ documented)Engines notifiedAll IndexNow-compatible engines share submissions---

Testing
-------

[](#testing)

The package uses [Pest](https://pestphp.com/) with [Orchestra Testbench](https://github.com/orchestral/testbench).

Run the test suite:

```
composer test
```

### Mocking in your application

[](#mocking-in-your-application)

Both clients are bound as singletons and can be swapped in tests:

```
use DancingJanissary\SeoIndexing\Clients\GoogleIndexingClient;
use DancingJanissary\SeoIndexing\Data\IndexingResult;

// Mock the Google client in a feature test
$this->mock(GoogleIndexingClient::class)
    ->shouldReceive('submit')
    ->once()
    ->with('https://example.com/page', 'URL_UPDATED')
    ->andReturn(IndexingResult::success(
        engine:     'google',
        url:        'https://example.com/page',
        action:     'URL_UPDATED',
        httpStatus: 200,
    ));

// Now trigger the model event
Page::factory()->create(['slug' => 'page', 'status' => 'published']);
```

### Disabling indexing in tests

[](#disabling-indexing-in-tests)

Add this to your `TestCase` base class to disable all API submissions during the test suite:

```
protected function setUp(): void
{
    parent::setUp();

    // Disable all indexing submissions in tests
    config(['seo-indexing.engines' => ['google' => false, 'indexnow' => false]]);
}
```

---

Changelog
---------

[](#changelog)

See [CHANGELOG.md](CHANGELOG.md) for release history.

---

License
-------

[](#license)

The MIT License (MIT). See [LICENSE](LICENSE) for details.

###  Health Score

38

—

LowBetter than 85% of packages

Maintenance89

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity51

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

55d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/5fb67216c66231fe81a992013eba4985d19e7452b922fe0fe732d37a4f0ef636?d=identicon)[dancing.janissary](/maintainers/dancing.janissary)

---

Top Contributors

[![dancing-janissary](https://avatars.githubusercontent.com/u/45737685?v=4)](https://github.com/dancing-janissary "dancing-janissary (13 commits)")

---

Tags

laravelgoogleSitemapseoseznamyandexindexingsearch enginebingindexnownaversearch console

###  Code Quality

TestsPest

### Embed Badge

![Health badge](/badges/dancing-janissary-laravel-seo-indexing/health.svg)

```
[![Health](https://phpackages.com/badges/dancing-janissary-laravel-seo-indexing/health.svg)](https://phpackages.com/packages/dancing-janissary-laravel-seo-indexing)
```

###  Alternatives

[essa/api-tool-kit

set of tools to build an api with laravel

52680.5k](/packages/essa-api-tool-kit)[flat3/lodata

OData v4.01 Producer for Laravel

96320.9k](/packages/flat3-lodata)[schulzefelix/laravel-search-console

A Laravel package to retrieve data from Google Search Console

5037.8k1](/packages/schulzefelix-laravel-search-console)[ymigval/laravel-indexnow

Laravel Service Library for notifying search engines about the latest content changes on their URLs using IndexNow.

3410.2k](/packages/ymigval-laravel-indexnow)[joggapp/laravel-aws-sns

Laravel package for the SNS events by AWS

3171.8k](/packages/joggapp-laravel-aws-sns)[simplestats-io/laravel-client

Client for SimpleStats!

4515.5k](/packages/simplestats-io-laravel-client)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
