> ## Documentation Index
> Fetch the complete documentation index at: https://docs.filarank.com/llms.txt
> Use this file to discover all available pages before exploring further.

# FilaRank

> SEO toolkit for Filament v5: live scoring, GEO/AEO, AI fixes, sitemaps, rank tracking, and head tags. Single-file overview for the Filament plugin directory.

<img src="https://docs.filarank.com/images/features/live-scoring.png" alt="FilaRank live SEO scoring in Filament" class="filament-hidden" />

# FilaRank, Filament's SEO Toolkit

SEO toolkit for [Filament](https://filamentphp.com) v5: live content scoring, readability and GEO/AEO analysis, SERP preview, head-tag rendering, XML sitemaps, redirect management, AI fix suggestions, keyword research, rank tracking, and site-wide health reports.

* Live demo: [filarank.com](https://filarank.com)
* Full docs: [docs.filarank.com](https://docs.filarank.com)
* Checkout: [Anystack](https://checkout.anystack.sh/filarank-pro/a22ff325-a480-49c5-bd18-8e9c01990f79)

## Features

* **26 analysis checks** across three score groups:
  * **SEO** (13), title length, keyword in title / meta description / slug / first paragraph / subheadings, keyword density with stuffing detection, content length, cornerstone depth, internal links, outbound links, image alt text.
  * **Readability** (7), Flesch Reading Ease, sentence length distribution, paragraph length, passive voice, transition words, subheading distribution, repeated consecutive sentence openings.
  * **GEO / AEO** (6), direct-answer blocks, FAQ structure, entity clarity, citation signals, snippet-length meta, structured-data readiness.
* **Live analysis in the form**, results re-render as the editor types (debounced), with traffic-light indicators and an overall 0–100 score.
* **AI Fix**, one-click title / meta / slug suggestions with a before/after diff. Bring your own OpenAI, Gemini, or Anthropic key.
* **Google SERP snippet preview** with truncation, right where the editor needs it.
* **`SeoScoreColumn`**, a sortable red/amber/green badge for any Filament table.
* **Persisted scores**, recalculated whenever the parent model is saved, so table badges never need a live re-analysis.
* **`<x-filarank::tags />`**, one component that renders `<title>`, meta description, robots, canonical, Open Graph, Twitter Cards, and multi-type JSON-LD (`@graph`) with site-wide fallbacks.
* **XML sitemap**, `/filarank-sitemap.xml` built from your scanned models, with optional search-engine pings.
* **Admin dashboards**, SEO Overview, SEO Health (404s, cannibalization, orphans, under-linked cornerstones, technical audit), Keyword Research, Rank Tracker, Backlinks, Competitors, Redirects, and Settings. Every page is individually toggleable.
* **Framework-free analysis engine**, `src/Analysis` and `src/Support` have zero Laravel dependencies, so the engine is trivially unit-testable and reusable outside Filament.
* **Interactive installer**, `php artisan filarank:install` publishes assets, migrates, and wires the trait, form section, and score column into the models and resources you pick, with a `--dry-run` mode.
* Fully translatable, publishable views, configurable checks.

New in v2.0: AI features, GEO/AEO checks, sitemaps, the schema registry, the technical audit, keyword research, rank tracking, backlinks, and competitors. See [Upgrading from v1](https://docs.filarank.com/upgrading) if you already have FilaRank installed.

## Requirements

* PHP 8.3+
* Laravel 11 / 12 / 13
* Filament v5

Optional: [`laravel/head`](https://laravel.com/docs/head) (Laravel 13.17+) if you would rather render tags through Laravel's first-party `@head` directive. See [Using Laravel Head](#using-laravel-head).

## Installation

```bash theme={null}
composer require usamamuneerchaudhary/filarank

php artisan filarank:install
```

The interactive installer publishes the config and migrations, offers to run `migrate`, then shows a checklist of your `app/Models` classes. For each model you pick it will:

1. add the `HasSeo` trait (plus import) to the model,
2. find the matching Filament resource anywhere under `app/Filament`, and, with your confirmation, append `SeoFields::make()` to the form and `SeoScoreColumn::make()` to the table, with imports.

Patching is deliberately conservative: files are only modified when the expected structure is found, every edit is idempotent (safe to re-run), and anything that can't be patched automatically falls back to printed manual instructions. Use `--dry-run` to preview without writing:

```bash theme={null}
php artisan filarank:install --dry-run
```

Prefer to wire things up by hand? The manual steps below are exactly what the installer automates:

```bash theme={null}
php artisan vendor:publish --tag=filarank-migrations
php artisan migrate
```

Optionally publish the config, views, or translations:

```bash theme={null}
php artisan vendor:publish --tag=filarank-config
php artisan vendor:publish --tag=filarank-views
php artisan vendor:publish --tag=filarank-translations
```

### Register the panel plugin

The admin pages live behind a plugin so you only get the ones you want:

```php theme={null}
use Usamamuneerchaudhary\FilaRank\FilaRankPlugin;

public function panel(Panel $panel): Panel
{
    return $panel->plugin(FilaRankPlugin::make());
}
```

Every page defaults to on and can be switched off individually:

```php theme={null}
FilaRankPlugin::make()
    ->overview()             // SEO Overview dashboard
    ->healthDashboard()      // 404s, cannibalization, orphans, technical audit
    ->keywordResearch()
    ->rankTracker()
    ->backlinks(false)       // hide the pages you don't need
    ->competitors(false)
    ->settings()
    ->redirects()
```

## Usage

### 1. Prepare your model

```php theme={null}
use Usamamuneerchaudhary\FilaRank\Concerns\HasSeo;

class Post extends Model
{
    use HasSeo;
}
```

By default the analyzer reads the model's `content` and `slug` attributes and falls back to `title` for the page title. Override if your columns differ:

```php theme={null}
public function getSeoContent(): ?string
{
    return $this->body;
}

public function getSeoSlug(): ?string
{
    return $this->permalink;
}

/** Public URL, used by the sitemap, link graph, and technical audit. */
public function getSeoUrl(): ?string
{
    return route('posts.show', $this, absolute: false);
}
```

### 2. Add the SEO section to your Filament form

```php theme={null}
use Usamamuneerchaudhary\FilaRank\Forms\SeoFields;

public static function form(Schema $schema): Schema
{
    return $schema->components([
        TextInput::make('title'),
        TextInput::make('slug'),
        RichEditor::make('content'),

        SeoFields::make(), // assumes `content` + `slug` fields on this form
    ]);
}
```

If your body field has a different name, or lives somewhere unusual in the schema tree:

```php theme={null}
SeoFields::make(contentField: 'body', slugField: 'permalink');

// or take full control of where values are read from and written to:
SeoFields::make(
    getContentUsing: fn (Get $get) => $get('../body'),
    getSlugUsing: fn (Get $get) => $get('../permalink'),
    setSlugUsing: fn (string $value, Set $set) => $set('../permalink', $value),
);
```

> `SeoFields::make()` binds to the `seo` relationship, so it must be used on a form with a record model that uses `HasSeo`.

### 3. Show the score in your table

```php theme={null}
use Usamamuneerchaudhary\FilaRank\Tables\SeoScoreColumn;

public static function table(Table $table): Table
{
    return $table->columns([
        TextColumn::make('title'),
        SeoScoreColumn::make(),
    ]);
}
```

Scores are recalculated and stored whenever the model is saved (disable with `'persist_score' => false`).

### 4. Render the tags on your frontend

In your layout's `<head>`:

```blade theme={null}
<x-filarank::tags :model="$post" />
```

Or for static pages without a model:

```blade theme={null}
<x-filarank::tags title="Contact us" description="Get in touch with our team." />
```

#### Using Laravel Head

Laravel 13.17 shipped [`laravel/head`](https://laravel.com/docs/head), a first-party way to manage the document `<head>`. FilaRank works with it, so you don't have to choose between the panel you're editing SEO in and the head management the rest of your app uses.

It stays optional. FilaRank supports Laravel 11 and 12, `laravel/head` requires 13.17+, so it is a `suggest` rather than a `require`. Nothing changes if you don't install it.

```bash theme={null}
composer require laravel/head
```

Then push the record's stored meta before the view renders, instead of placing the component:

```php theme={null}
use Usamamuneerchaudhary\FilaRank\SeoHead;

public function show(Post $post): View
{
    SeoHead::for($post);

    return view('posts.show', ['post' => $post]);
}
```

Static pages take the same overrides as the component:

```php theme={null}
SeoHead::for(null, title: 'Contact us', description: 'Get in touch with our team.');
```

Keep Laravel Head's `@head` directive in your layout and drop `<x-filarank::tags />`. **Render one or the other, never both**, or every page gets two titles and two JSON-LD blocks.

Both renderers read the same resolution chain (explicit argument, then the stored SEO row, then your model's fallbacks, then site config) and emit the same title, description, canonical, robots, Open Graph, Twitter card, and JSON-LD graph. Two deliberate differences:

* Laravel Head normalizes canonical URLs, forcing `https` and giving a root path its trailing slash. The component emits whatever you stored, verbatim.
* FilaRank's JSON-LD is scrubbed recursively before handover, because Laravel Head rejects null and empty-string values at any depth. This mostly matters if your model's `getProductSchema()` returns partially-filled nested nodes.

`SeoHead::for()` throws if `laravel/head` isn't installed. Guard with `SeoHead::isAvailable()` if you ship code that has to run either way.

### 5. Analyze anything programmatically

```php theme={null}
use Usamamuneerchaudhary\FilaRank\SeoAnalyzer;

$report = SeoAnalyzer::analyze([
    'title' => 'Coffee Beans Guide',
    'description' => '…',
    'focus_keyword' => 'coffee beans',
    'slug' => 'coffee-beans',
    'content' => $html,
]);

$report->score();            // 0–100 or null
$report->seoScore();
$report->readabilityScore();
$report->geoScore();
$report->rating();           // Status::Good | Ok | Bad
$report->results;            // list of CheckResult
```

Or against a model directly: `SeoAnalyzer::analyzeModel($post)`.

## AI features

FilaRank is bring-your-own-key. Add a key under **FilaRank SEO → SEO Settings** (stored encrypted via `Crypt`), or fall back to env: `OPENAI_API_KEY`, `GEMINI_API_KEY`, `ANTHROPIC_API_KEY`.

| Feature            | Where                          | What it does                                                                                        |
| ------------------ | ------------------------------ | --------------------------------------------------------------------------------------------------- |
| Fix with AI        | SEO section header, per record | Suggests title, meta description, and slug, shown as a before/after diff before anything is written |
| Audit fix plan     | SEO Health → Technical audit   | Turns an audit issue into a numbered remediation plan                                               |
| Keyword enrichment | Keyword Research               | Adds intent, difficulty, and volume estimates to a seed keyword                                     |
| Competitor gaps    | Competitors                    | Suggests keywords a competitor likely targets that you don't                                        |
| Backlink toxicity  | Backlinks                      | Scores a link 0–100 for spamminess                                                                  |

Every call is logged to `filarank_ai_logs` with provider, model, token counts, and estimated cost, surfaced as a monthly spend figure on the Overview dashboard. Nothing runs unless a key is configured, AI actions hide themselves when `AiService::isConfigured()` is false.

### Choosing a model

The model dropdown in SEO Settings is built from config, and that list is the allowlist, add a model and it becomes selectable, with no code change and nothing hardcoded on the package side:

```php theme={null}
'ai' => [
    'providers' => [
        'openai' => [
            'api_key' => env('OPENAI_API_KEY'),
            'models' => ['gpt-4o-mini', 'gpt-4o', 'your-new-model'],
            'rates' => [
                'default' => ['in' => 0.15, 'out' => 0.60],
                'gpt-4o' => ['in' => 2.50, 'out' => 10.00],
            ],
        ],
    ],
],
```

The first entry is the default when nothing has been chosen, so put your preferred model first. A saved selection is validated against the list on every read, so removing a model from config quietly falls back to that first entry rather than erroring.

`rates` are USD per 1M tokens and drive the spend estimate. Key a rate by model name to price it exactly; anything without its own entry uses `default`. Getting this wrong only skews the dashboard figure, never a request.

Two caveats when adding a model. Requests are built per provider, not per model, the OpenAI provider sends `temperature` and a JSON response format, so reasoning-style models that reject a custom temperature will fail with a 400. And because publishing the config replaces the package's `ai` block wholesale (`mergeConfigFrom` merges only top-level keys), a published copy won't inherit providers added in later versions. Either way, **Test AI connection** in Settings does a real round trip and surfaces the problem immediately.

To use a provider directly, or in tests:

```php theme={null}
use Usamamuneerchaudhary\FilaRank\Ai\AiService;
use Usamamuneerchaudhary\FilaRank\Ai\Providers\FakeProvider;

$suggestion = app(AiService::class)->suggestSeoFix([
    'title' => $post->seo->title,
    'description' => $post->seo->description,
    'focus_keyword' => 'coffee beans',
    'slug' => $post->slug,
    'content' => $post->content,
]);

// Deterministic output for tests (local/testing environments only):
(new AiService)->useProvider(new FakeProvider)->suggestSeoFix([...]);
```

All provider failures are wrapped in `AiRequestException` with credentials stripped out, so a bad key or rate limit never leaks a secret into your logs or a notification.

## Sitemap

Enable the sitemap in **SEO Settings**; while it's off, the route returns 404 rather than an empty document. Entries are built from your scanned models, skipping records flagged `noindex` or without a public URL.

```php theme={null}
'sitemap' => [
    'enabled' => true,
    'path' => 'filarank-sitemap.xml',
    'changefreq' => 'weekly',
    'priority' => 0.5,
    'cache_seconds' => 3600,

    'ping' => false,
    'ping_endpoints' => [
        'https://www.bing.com/ping?sitemap={sitemap}',
    ],
],
```

With pinging enabled, endpoints are called after a record is saved. Requests are deferred until after the response, so a slow or dead endpoint never delays a content save. The sitemap placeholder in each ping URL is replaced with the URL-encoded sitemap URL. Google retired its ping endpoint in 2023, so the list is deliberately easy to repoint.

## Technical audit

**SEO Health → Technical audit** crawls the public URL of every record with SEO meta and reports missing titles, meta descriptions, canonicals, OG images, and focus keywords, plus broken status codes, absent JSON-LD, and images without alt text.

The crawler only fetches URLs on your own `app.url` host. Extra hosts must be allow-listed *and* must resolve to public addresses, which stops a careless `getSeoUrl()` from pointing the crawler at internal services or cloud metadata endpoints. Blocked URLs are recorded as an `unsafe_url` issue so the meta checks still run and you can see why the fetch was skipped.

```php theme={null}
'audit' => [
    'allowed_hosts' => ['docs.example.com'],
    'max_pages' => 500,
    'delay_ms' => 0,       // throttle between requests
],
```

Runs are bounded by `max_pages` and atomic: issues are collected in memory and swapped in one transaction, so a crawl that dies halfway leaves your existing issue list intact.

## Keyword research & rank tracking

Research pulls Google Autocomplete suggestions plus modifier expansions for a seed keyword, saves them as untracked rows, and lets you promote the ones you care about to tracked keywords. The table supports search, sorting, filtering by tracked state, and bulk track/dismiss.

```php theme={null}
use Usamamuneerchaudhary\FilaRank\Keywords\KeywordStore;

$store = app(KeywordStore::class);
$store->saveResearched(['coffee beans', 'best coffee beans']);
$store->track('coffee beans');       // never clears existing enrichment
$store->dismiss([$id]);              // untracked rows only; tracked keywords are safe
```

Tracked keywords get daily position checks. SerpAPI is the supported provider, add the key in SEO Settings:

```bash theme={null}
php artisan filarank:check-ranks
```

The command is scheduled automatically (`rank_tracker.schedule_at`, default 03:00) and exits cleanly with a warning when no key is configured, so an unconfigured install doesn't fail every night. Scraping Google breaks their terms of service and stops working without warning, so it's disabled unless you opt in with `FILARANK_ALLOW_SCRAPE=true`; until then the option is hidden from Settings.

Research rows accumulate, so prune them when you're done:

```bash theme={null}
php artisan filarank:prune-keywords --days=30          # dry run, lists what would go
php artisan filarank:prune-keywords --days=30 --force  # actually delete
```

Tracked keywords are never pruned, they hold your rank history.

## Backlinks & competitors

Both are deliberately simple: you own the data, FilaRank enriches it.

**Backlinks** imports a CSV export from Ahrefs, Semrush, or Search Console. Headers are matched flexibly (`source_url`/`url`/`referring page`, `dr`/`domain_rating`, and so on), and rows are matched on source + target URL, so re-importing a refreshed export updates rows instead of duplicating them. Optional AI toxicity scoring runs 25 unscored links at a time.

**Competitors** is a domain list with an "Analyse gap" action that asks the model which keywords a competitor likely targets that you don't. These are informed estimates from the domain and your own keyword list, not crawled ranking data, treat them as research prompts, not measurements.

## Configuration

See `config/filarank.php` for site-wide defaults (site name, title separator, default description / share image, Twitter handle), tag-rendering toggles, schema types, disabled checks, and score persistence.

Disable individual checks:

```php theme={null}
'analysis' => [
    'disabled_checks' => ['outbound-links', 'passive-voice'],
],
```

Choose which JSON-LD nodes are emitted (also toggleable from SEO Settings):

```php theme={null}
'render' => [
    'schema_types' => [
        'organization' => true,
        'website' => true,
        'breadcrumb' => true,
        'article' => true,
        'faq' => true,
        'product' => false,
    ],
],
```

List the models FilaRank should scan for the sitemap, audit, cannibalization, and link graph:

```php theme={null}
'scanned_models' => [
    \App\Models\Post::class => ['label' => 'title'],
    \App\Models\Page::class => ['label' => 'name'],
],
```

Leave it empty and FilaRank falls back to any model that already has SEO meta rows, so the dashboards aren't blank on a fresh install.

## Security notes

* **API keys** are encrypted at rest and shown masked in the UI. Paste a new value to replace one; leave the field blank to keep it.
* **Provider errors** pass through `Support\Redactor` before being logged or displayed, stripping query-string credentials, bearer tokens, and known key formats.
* **The audit crawler** is host-restricted (see [Technical audit](#technical-audit)) and refuses redirects to untrusted hosts mid-request.
* **The fake AI provider** is refused outside `local` and `testing`, so a misconfigured production panel can't silently serve canned copy.
* **Google scraping** is off by default and must be enabled explicitly in config.

## Writing your own check

Implement the `Check` contract and register it:

```php theme={null}
use Usamamuneerchaudhary\FilaRank\Analysis\Contracts\Check;
use Usamamuneerchaudhary\FilaRank\Analysis\{CheckResult, ContentContext, Status};

final class NoClickbaitTitle implements Check
{
    public function id(): string { return 'no-clickbait'; }
    public function group(): string { return 'seo'; }

    public function isApplicable(ContentContext $context): bool
    {
        return filled($context->title);
    }

    public function run(ContentContext $context): CheckResult
    {
        $clickbait = str_contains(mb_strtolower($context->title), 'you won\'t believe');

        return new CheckResult(
            $this->id(),
            $this->group(),
            $clickbait ? Status::Bad : Status::Good,
            $clickbait ? 'Avoid clickbait phrasing in titles.' : 'Title looks trustworthy.',
        );
    }
}
```

```php theme={null}
$report = SeoAnalyzer::analyzer()
    ->withCheck(new NoClickbaitTitle())
    ->analyze($context);
```

Groups are `seo`, `readability`, and `geo`; anything else is ignored by the score panels.

## Cross-record reports

All the cross-record logic lives in framework-free, unit-tested engines (`src/Cannibalization`, `src/Linking`, `src/Redirects`, `src/Language`, `src/Keyphrase`).

### Redirect manager (404 capture + one-click 301s)

Register the middleware in `bootstrap/app.php`:

```php theme={null}
use Usamamuneerchaudhary\FilaRank\Http\Middleware\HandleRedirects;

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [HandleRedirects::class]);
})
```

The middleware serves matching redirects (with normalisation, chain-following A→B→C, and loop protection) and logs every 404. Logged 404s appear on the **SEO Health** page with a one-click "Create redirect" link that pre-fills the source path. Rules are cached for an hour and the cache is busted automatically on create/edit/delete.

Slug changes on published records create a 301 automatically. Reverting a slug clears the reverse rule, so renaming back and forth can't leave a redirect loop behind. Configure with:

```php theme={null}
'redirects' => [
    'auto_on_slug_change' => true,
    'only_when_published' => true,
],
```

### Internal linking suggestions & orphaned content

The SEO Health dashboard reports **orphaned content** (pages nothing links to) and **under-linked cornerstones**. The link graph compares URLs by normalised path, so absolute and relative links to the same page match. Programmatic access:

```php theme={null}
$graph = app(\Usamamuneerchaudhary\FilaRank\Reports\SeoReports::class)->linkGraph();
$graph->orphans();
$graph->suggestionsFor($record);       // keyphrase-based, cornerstone-boosted
$graph->underlinkedCornerstones(3);
```

### Keyword cannibalization warnings

Any keyphrase targeted by more than one record is flagged, sorted most-contested first. Keywords are normalised (case- and slug-insensitive), so "Coffee Beans", "coffee beans", and "coffee-beans" collide. Includes each record's primary **and** related keyphrases.

### Cornerstone content flagging

A **Cornerstone content** toggle in the SEO section marks your most important pages. Cornerstone records are held to a higher bar by the `cornerstone-depth` check (≥900 words, properly subheaded) and flagged on the dashboard when too few internal links point to them (`config('filarank.cornerstone.min_incoming_links')`).

### Multiple focus keywords

A **Related keyphrases** tags field lets each record target secondary phrases. The primary keyword gets the full analysis; each related keyphrase gets the keyword-specific SEO checks (title / meta / slug / first paragraph / density / subheadings), shown in the analysis panel. Duplicates of the primary are de-duplicated automatically.

```php theme={null}
use Usamamuneerchaudhary\FilaRank\Keyphrase\KeyphraseAnalyzer;

$results = (new KeyphraseAnalyzer())->analyze($context); // primary first, then related
```

### Per-language readability packs

Readability heuristics (transition words, passive-voice detection, syllable counting, reading-ease formula) sit behind a `LanguagePack` interface. English and Dutch ship in the box; a **Content language** selector per record picks the pack, defaulting to your app locale. Add your own:

```php theme={null}
use Usamamuneerchaudhary\FilaRank\Language\LanguageRegistry;

app(LanguageRegistry::class)->register(new FrenchPack());
```

Extend `AbstractLanguagePack` and implement `transitionWords()`, `countSyllables()`, and the passive-voice word lists, see `EnglishPack` and `DutchPack` for the pattern.

## Styling

The panel UI is built from native Filament components plus hand-written `filarank-*` classes layered on Filament's design tokens, inlined once per process. There is no Tailwind build step and no `viteTheme` requirement, the plugin looks right in any panel, light or dark, out of the box.

## Testing

```bash theme={null}
composer test              # Pest (Laravel integration, requires composer install)
composer test:standalone   # both engine suites, zero dependencies, just PHP
```

## Roadmap ideas

* More language packs (French, German, Spanish, …)
* Bulk internal-link insertion from suggestions
* AI content writer (draft full articles from keyword research)
* IndexNow support alongside sitemap pings
* Google Search Console import for real impression and position data

## License

Commercial. One license covers one production project (its development and staging environments included). Purchase at [Anystack](https://checkout.anystack.sh/filarank-pro/a22ff325-a480-49c5-bd18-8e9c01990f79).

## Support

Email [hello@usamamuneer.me](mailto:hello@usamamuneer.me).

Full documentation: [docs.filarank.com](https://docs.filarank.com)
