SEO Files plugin screenshot
Dark mode ready
Multilingual support
Supports v5.x

SEO Files

Community

Generate and edit sitemap.xml with hreflang and a sitemap index for large sites, robots.txt and llms.txt / llms-full.txt from your Filament panel

Tags: Panels Developer Tool
Supported versions:
5.x
asign avatar Author: asign

Package health

Beta

Automated checks of this plugin's Composer package

98 / 100
Security 96
Maintenance 100
Ecosystem 100
15 checks
  • Passed: GitHub Actions pinned to SHA
  • Skipped: GitLab CI includes pinned to SHA
  • Passed: Open security advisories
  • Passed: Dependabot PR responsiveness — No open Dependabot PRs.
  • Skipped: Renovate MR responsiveness
  • Warning: Dependabot or Renovate configured — Updater does not cover the JavaScript ecosystem, which has a committed lockfile.
  • Passed: Dependency update cooldown configured
  • Passed: Provides a security policy
  • Passed: Abandoned or archived — No consulted source marks the package abandoned (packagist, github).
  • Passed: Commit and release recency — Active: last commit 0 days ago; last release 0 days ago.
  • Passed: composer.lock not committed by library — composer.lock is absent from the released dist archive.
  • Passed: Dist archive is lean
  • Passed: Current Laravel version supported — Package dependencies resolve together with current Laravel 13.0.
  • Passed: Current PHP version supported — Constraint ^8.3 supports current PHP 8.5.
  • Skipped: Current Symfony version supported
Third-party plugin. This is built by the community, not the Filament team. Filament does not review, endorse, or vet the security of plugins outside the filament/ namespace. Review the source and install at your own risk. Found malware or an unresolved security issue the author won't address? Report it .
Powered by Plumb Last scanned 12 hours ago

Documentation

Stand With Ukraine Latest Version on Packagist Tests Total Downloads License Plumb score

Filament SEO Files

The files crawlers and AI agents read — sitemap.xml, robots.txt, llms.txt and llms-full.txt — generated from your own models and managed from a Filament panel.

Sitemap plugins stop at the sitemap, and nobody generates llms.txt at all. This one does all four, with the details that are easy to get wrong: hreflang clusters that are reciprocal, x-default, manual URLs that can never shadow a real page, a sitemap index once a site outgrows 50 000 URLs, and per-language llms.txt served without breaking your home page.

#Screenshots

The "SEO files" page: generate the sitemap and llms files, edit robots.txt.

The SEO files page

The SEO files page, dark mode

Manual "Sitemap URLs": pages no source knows about.

Sitemap URLs table

One record is one multilingual URL, with a live preview of the final address.

Sitemap URL form

The generated llms.txt, editable per language.

llms.txt editor

The generated sitemap.xml with reciprocal hreflang alternates and x-default.

Generated sitemap.xml

#Requirements

  • PHP 8.3+
  • Laravel 12 or 13
  • Filament 5 (only for the panel UI — the generation itself runs anywhere)

#Installation

composer require asignua/filament-seo-files

Publish and run the migration for the manual "Sitemap URLs" table (skip it if you do not use that resource):

php artisan vendor:publish --tag="filament-seo-files-migrations"
php artisan migrate

Optionally publish the config (every key is commented) and the translations:

php artisan vendor:publish --tag="filament-seo-files-config"
php artisan vendor:publish --tag="filament-seo-files-translations"

Register the plugin in your panel provider to get the page and the resource:

use Asignua\FilamentSeoFiles\SeoFilesPlugin;

$panel->plugin(SeoFilesPlugin::make()
    ->authorize(fn (): bool => auth()->user()?->isAdmin()));

Say who may use it. Without ->authorize(...) or a seo-files.manage gate nobody can open the page, the resource or the actions: they write robots.txt and the llms files into the web root, and one Disallow: / de-indexes the site. ->authorize(true) lets in everyone who can enter the panel (see Authorization).

The package has two layers. The registry SeoFiles holds your sources and resolvers and works everywhere — console, scheduler, queue, routes — with or without a panel. The plugin SeoFilesPlugin only draws the panel UI.

#Styling

The views use a few Tailwind utilities that Filament's own stylesheet does not contain. The plugin ships them as a small compiled file (resources/dist/filament-seo-files.css, no preflight) and links it after the panel's styles, so no custom theme or @source line is needed. Publish the file after installing or upgrading:

php artisan filament:assets

The stylesheet is linked by the plugin registered in the panel. Editing the views? Rebuild with npm install && npm run build.

#Quick start

In AppServiceProvider::boot() tell the registry where your pages are:

use Asignua\FilamentSeoFiles\SeoFiles;
use Asignua\FilamentSeoFiles\Sources\ModelSource;

SeoFiles::source(
    ModelSource::make(Post::class)
        ->query(fn ($query) => $query->where('published', true))
        ->url(fn (Post $post, string $locale): ?string => route('posts.show', $post))
        ->title(fn (Post $post, string $locale): string => $post->title)
        ->description(fn (Post $post, string $locale): ?string => $post->excerpt)
        ->body(fn (Post $post, string $locale): string => $post->content) // HTML or Markdown
        ->section('Blog'),
);

Then:

php artisan seo-files:sitemap   # public/sitemap.xml
php artisan seo-files:llms      # public/llms.txt and public/llms-full.txt

That is the whole setup for a single-language site. The base URL defaults to app.url.

What ModelSource does for you:

  • reads the table in chunks by primary key, so a large table is never loaded whole;
  • skips a record in a language where url() returns null;
  • <lastmod> is updated_at unless you pass ->lastModified(fn (Post $post) => …);
  • the llms.txt description is stripped of tags and cut to 200 characters (llms.description_limit);
  • an HTML body is converted to Markdown with league/html-to-markdown; call ->markdown() when the body closure already returns Markdown;
  • a url() result that starts with / gets the base URL prepended, an absolute one is used as is;
  • ->locales(['en']) restricts a source to some languages, ->chunk(500) changes the chunk size, ->limit(200) caps the records in llms-full.txt, which carries whole page bodies;
  • llms.txt is a short index, so a source lists at most llms.index_limit (100) records there, newest first by primary key; ->indexLimit(20) changes it per source, ->indexLimit(null) lists every record. The sitemap is never limited.

#Multilingual sites

SeoFiles::locales(default: 'en', all: ['en', 'uk', 'de'], unprefixed: 'en');
  • default is the language of <loc> and x-default.
  • unprefixed is the language served without a /{locale}/ URL prefix (null if every language is prefixed).
  • When the languages live in a config that can change after boot (a CMS), use SeoFiles::localesUsing(fn () => ['default' => …, 'all' => […], 'unprefixed' => …]): it is resolved at the moment of use.

For each page ModelSource asks your url() closure once per language. Every language version becomes a <url> of its own, and each of them lists the full set of hreflang alternates (itself included) plus x-default, which points at the default language (or the first language that has a URL) — the reciprocal cluster Google's sitemap method expects. hreflang values are BCP 47: a pt_BR site language is written as pt-BR.

Four more resolvers adapt the plugin to how your site builds URLs and names itself:

SeoFiles::baseUrlUsing(fn (): string => config('app.url'));            // default: app.url
SeoFiles::localizedUrlUsing(fn (string $locale, string $path): string => …); // default: {base}/[{locale}/]{path}
SeoFiles::siteNameUsing(fn (string $locale): string => …);             // default: app.name
SeoFiles::descriptionUsing(fn (string $locale): ?string => …);         // default: none

baseUrlUsing feeds both sitemap.xml and robots.txt, so they can never point at different hosts. A scheduler has no request, so the base URL always comes from here, never from url().

#Custom sources

A source implements any subset of three contracts and is registered with SeoFiles::source(...):

use Asignua\FilamentSeoFiles\Contracts\LlmsFullSource;
use Asignua\FilamentSeoFiles\Contracts\LlmsIndexSource;
use Asignua\FilamentSeoFiles\Contracts\SitemapSource;
use Asignua\FilamentSeoFiles\Data\LlmsDocument;
use Asignua\FilamentSeoFiles\Data\LlmsLink;
use Asignua\FilamentSeoFiles\Data\LlmsSection;
use Asignua\FilamentSeoFiles\Data\SitemapEntry;

class CatalogSource implements LlmsFullSource, LlmsIndexSource, SitemapSource
{
    public function sitemapEntries(): iterable
    {
        foreach (Product::query()->lazyById() as $product) {
            yield new SitemapEntry(
                url: "https://shop.test/p/{$product->slug}",                 // the default language
                alternates: ['en' => "https://shop.test/p/{$product->slug}",
                             'uk' => "https://shop.test/uk/p/{$product->slug}"],
                lastModified: $product->updated_at,                          // optional
            );
        }
    }

    public function llmsSections(string $locale): iterable
    {
        yield new LlmsSection('Catalogue', [
            new LlmsLink('All products', 'https://shop.test/p', 'The full catalogue'),
        ]);
    }

    public function llmsDocuments(string $locale): iterable
    {
        yield new LlmsDocument('Delivery', 'https://shop.test/delivery', "# Delivery\n\nWe ship worldwide.");
    }
}

Return a generator for large tables — the sitemap is generated as a stream. A hreflang cluster is an entry, so it is never cut between two sitemap files.

#Manual sitemap URLs

Pages no source knows about — a search page, RSS feeds, an external landing page — live in the Sitemap URLs resource (the migration above creates its table). One record is one multilingual <url>:

  • the value is a path from the site root without the language prefix (search) — the plugin adds the host and the /{locale}/ prefix — or a full https://… address, used as is;
  • the form rejects spaces, invalid addresses, ftp:// and //host, paths that start with a language prefix, paths your site already owns, and an address another manual record already has in that language. The root path / is accepted (it is the home page of each language) unless ownedPathUsing says the site owns '';
  • tell the plugin what the site owns, so a manual URL can never duplicate a real page:
SeoFiles::ownedPathUsing(fn (string $locale, string $path): bool => Page::query()->where('slug', $path)->exists());

Sources are written first and manual records second, and an address that was already emitted — as a <loc> or as an alternate — is never emitted again. A manual record cannot override a real page.

Writes go through Asignua\FilamentSeoFiles\Repositories\SitemapUrlRepository (the model has $guarded = ['*']). To extend the model — to add an audit trail, say — subclass it and point filament-seo-files.models.sitemap_url at your class.

#Large sites: the sitemap index

The protocol allows 50 000 URLs or 50 MB (uncompressed) per file. Generation is streaming: tags collect in a buffer of sitemap.max_urls, and on overflow the buffer is written as a part (sitemap-1.xml, sitemap-2.xml, …) next to sitemap.xml, which then becomes a <sitemapindex> with each part's <lastmod>.

Option Default
sitemap.max_urls 50000 URLs per part
sitemap.split auto auto: one file while the URLs fit; always: always an index; never: always one file
sitemap.chunk_name sitemap-{n}.xml name of a part
sitemap.max_bytes 45000000 a part is closed early at this estimated size

The deduplication map is shared by all parts, a hreflang cluster is never split, parts of an earlier run that this run did not write are deleted (only files that match chunk_name, only next to sitemap.xml), and every file is written to a temporary file and renamed, so a crawler never sees a half-written index. robots.txt and llms.txt keep pointing at sitemap.xml.

#robots.txt

RobotsFile reads and writes public/robots.txt. Until the file exists the recommended template is served by the editor:

User-agent: *
Content-Signal: search=yes, ai-input=yes, ai-train=no
Allow: /

Sitemap: https://example.com/sitemap.xml

Content-Signal says search and agent answers are welcome and model training is not; it sits inside the User-agent group on purpose. Replace the template:

SeoFiles::robotsTemplateUsing(fn (string $baseUrl, string $default): string => $default."\nDisallow: /admin");

Edit the live file from the panel ("SEO files" page → robots.txt → Edit, with a "Reset to template" button).

#llms.txt and llms-full.txt

llms.txt is a short curated index of the site for AI agents; llms-full.txt carries the full text of every page, separated by ---. One pair per language:

Language llms.txt llms-full.txt
the unprefixed one public/llms.txt public/llms-full.txt
a prefixed one public/.llms/{locale}.txt public/.llms/{locale}-full.txt

Prefixed languages are served at /{locale}/llms.txt and /{locale}/llms-full.txt by routes, which the package registers for you. They are not static files because a real public/{locale}/ directory would shadow your /{locale}/ home page (php artisan serve and nginx's try_files $uri $uri/ would serve the directory). The leading dot of .llms also keeps nginx from serving the stored files directly. When a language has no stored file yet, the first request builds it once and stores it (behind a cache lock; a request that arrives while another one is building gets 503 with Retry-After at once instead of waiting), and every later request reads the file — an anonymous visitor can never make the server rebuild the document per request. Run seo-files:llms to refresh the files.

The routes carry no middleware by default (routes.middleware): the web group would start a session and set a cookie on a plain text file, which keeps a CDN from caching it.

Sites with a catch-all route must register the routes before it, otherwise the catch-all swallows both URLs:

// config/filament-seo-files.php
'routes' => ['register' => false],

// routes/web.php
use Asignua\FilamentSeoFiles\SeoFiles;

SeoFiles::routes();            // before the catch-all
Route::get('/{path?}', …)->where('path', '.*');

The index is built from the sections of every LlmsIndexSource, plus an ## Optional section that points at sitemap.xml (the spec lets an agent skip it when it needs a shorter context). The blockquote under the title comes from SeoFiles::descriptionUsing(...); the language is checked against ^[a-z]{2,3}([-_][A-Za-z]{2,4})?$ before any path is built, for both files.

#Scheduling

Off by default. Turn it on and make sure the Laravel scheduler runs (php artisan schedule:work, or * * * * * php artisan schedule:run):

// config/filament-seo-files.php
'schedule' => [
    'enabled' => true,
    'times' => ['sitemap' => '04:00', 'llms' => '04:20'],
],

Both commands run daily and withoutOverlapping(): they write into public/, and two parallel writes into one file give a truncated file nobody notices. Without a scheduler process nothing runs, and the failure is silent — check it once. Add your own tasks with a plain withSchedule(); the registrations add up.

#Authorization

The plugin fails closed: unless you pass a closure or define a seo-files.manage gate, nobody can open the page, the resource or the actions. Pass a closure (it wins over the gate), define the gate, or call ->authorize(true) to allow everyone who can enter the panel:

$panel->plugin(SeoFilesPlugin::make()
    ->authorize(fn (): bool => auth()->user()?->isAdmin())
    ->navigationGroup('SEO')
    ->navigationSort(20));

->page(false) hides the "SEO files" page (when you embed the actions in your own page, see below) and ->resource(false) hides "Sitemap URLs".

#Commands

Command
php artisan seo-files:sitemap writes sitemap.xml (and the parts of an index)
php artisan seo-files:llms writes llms.txt and llms-full.txt for every language
php artisan seo-files:llms --locale=uk --locale=en only these languages

seo-files:llms rebuilds the files from the sources, so manual edits made in the panel's editor are replaced.

#Using the actions on your own page

The four buttons of the "SEO files" page are public Filament actions. Put them on any page, for instance an existing "Tools" page, and hide the stock page with ->page(false):

use Asignua\FilamentSeoFiles\Actions\EditLlmsAction;
use Asignua\FilamentSeoFiles\Actions\EditRobotsAction;
use Asignua\FilamentSeoFiles\Actions\GenerateLlmsAction;
use Asignua\FilamentSeoFiles\Actions\GenerateSitemapAction;

public function generateSitemapAction(): Action { return GenerateSitemapAction::make(); }
public function editRobotsAction(): Action { return EditRobotsAction::make(); }
public function generateLlmsAction(): Action { return GenerateLlmsAction::make(); }
public function editLlmsAction(): Action { return EditLlmsAction::make(); }

Render each with {{ $this->generateSitemapAction }} and keep <x-filament-actions::modals /> on the page. Each action carries the plugin's policy (SeoFilesPlugin::allows()) itself, so a host page with weaker access cannot open a back door: an unauthorized user does not see the buttons.

The generate actions run the command inside the request (with PHP's time limit lifted for it). On a very large site a proxy timeout can still cut it short — schedule the commands there instead. The llms "Generate" confirmation warns that editor changes are replaced, and with the schedule on both llms actions name the time of the nightly rewrite.

#Configuration

config/filament-seo-files.php — tables, models, the sitemap/robots/llms paths (all default to public_path() and are resolved at run time), the sitemap index limits, routes.register and routes.middleware, the schedule, and the llms description and index limits. Closures (sources, base URL, languages) cannot live in a cacheable config file and are set on the SeoFiles registry instead.

SeoFiles::flush() forgets everything configured on the registry — handy in tests.

#Translations

The interface ships in English, Ukrainian, German, Spanish, French, Italian, Dutch, Polish, Brazilian Portuguese and Turkish under the filament-seo-files::seo-files namespace. A test keeps every language in step with the English keys and placeholders. Override a string by publishing the translations and editing the copy in lang/vendor/filament-seo-files.

#AI agents

The package ships Laravel Boost guidelines (resources/boost/guidelines/core.blade.php) that describe the registry, ModelSource, the routes and the commands, so a coding agent wires it up correctly.

#Testing

composer install
vendor/bin/phpunit
vendor/bin/phpstan analyse --memory-limit=1G
vendor/bin/pint --test

The suite runs on Orchestra Testbench with a workbench/ panel and a Post model. Tests write into a throw-away public/, never the real one.

#License

The MIT License (MIT). See LICENSE.md.

The author

asign avatar Author: asign

asign is a small web-dev company from Lviv, Ukraine. We build business applications on Laravel and Filament — CRMs, automation systems for standard and non-standard business processes, booking and content management systems, including our own Filament-based CMS. We open-source the parts that prove useful beyond a single project

Plugins
8
Stars
3

From the same author