| Install | |
|---|---|
composer require muhammad-nawlo/filament-sitemap-generator |
|
| Latest Version: | 1.0.0 |
| PHP: | ^8.2 |
A config-driven Filament plugin for Laravel that generates XML sitemaps with optional splitting, news, images, alternates, and search-engine ping. Built on spatie/laravel-sitemap.
Compatibility: Filament v3.2+, v4.x, and v5.x. The Filament page uses getter overrides only for navigation group, label, and title (no static property redeclaration), so it stays compatible with differing parent types across Filament versions.
composer require muhammad-nawlo/filament-sitemap-generator
Publish the config file:
php artisan vendor:publish --tag="filament-sitemap-generator-config"
Configure config/filament-sitemap-generator.php (path, static URLs, models, schedule, queue, news, ping) as needed.
php artisan filament-sitemap-generator:generate (runs synchronously or dispatches a job if queue is enabled).schedule.enabled in config; the command is registered at your chosen frequency (e.g. daily).| Feature | Status | Notes |
|---|---|---|
| Manual generation via Filament | ✅ Implemented | Single page under Settings; one "Generate Sitemap" action |
| CLI generation | ✅ Implemented | php artisan filament-sitemap-generator:generate |
| Queue support | ✅ Implemented | Optional; config-driven connection and queue name |
| Scheduler support | ✅ Implemented | Optional; config-driven frequency (e.g. daily, hourly) |
| Config-driven static URLs | ✅ Implemented | static_urls array with url, priority, changefreq, lastmod |
| Model-based URLs | ✅ Implemented | models config; route or getSitemapUrl() per model |
| Chunked model processing | ✅ Implemented | Configurable chunk_size; never loads full table |
| 50,000 URL file splitting | ✅ Implemented | Configurable max_urls_per_file; flush when limit reached |
| Sitemap index generation | ✅ Implemented | When multiple parts exist, main path becomes index |
| lastmod support | ✅ Implemented | Static: config key; models: getSitemapLastModified() or updated_at |
| changefreq support | ✅ Implemented | Per static entry and per model config |
| priority support | ✅ Implemented | Per static entry and per model config |
| Alternate URLs (hreflang) | ✅ Implemented | Model method getAlternateUrls(); locale => url |
| Image sitemap support | ✅ Implemented | Model method getSitemapImages(); url + caption |
| Google News sitemap | ✅ Implemented | Separate sitemap-news.xml; 48-hour window; config-driven |
| Search engine ping | ✅ Implemented | Google and Bing; main sitemap URL only; try/catch per engine |
| Multi-site support | 🚧 Planned | Single site only; no tenant or domain-specific sitemaps |
| Storage disk abstraction | ✅ Implemented | Output mode file (path) or disk (Laravel disk); config or Filament Settings |
| Optional URL crawling | ✅ Implemented | Spatie crawler; merge with static/model URLs; dedupe; optional JS execution |
| Event hooks | ❌ Not supported | No before/after or URL-collected events; extension via config/service binding only |
Chunk-based model iteration: Models are read via Model::query()->chunk($chunkSize, callback). Only one chunk of records is in memory at a time. This avoids loading entire tables and keeps memory usage bounded by chunk size and the size of the in-memory sitemap (see below).
Memory usage: Peak memory is dominated by (1) one Spatie Sitemap instance holding up to max_urls_per_file URL tags (default 50,000), and (2) one chunk of Eloquent models (default 500 records). No full-table or full-sitemap accumulation in memory.
Max URLs per sitemap file: When the number of URLs added reaches max_urls_per_file (default 50,000), the current sitemap is written to sitemap-{n}.xml and a new in-memory sitemap is started. No single file exceeds this limit.
Index generation: If any part file is written, the main path (sitemap.xml) is written as a sitemap index that references all part URLs. If the total URL count never reaches the limit, a single sitemap is written to the main path and no index is produced.
Recommended queue usage for large sites: For sites with many thousands of URLs, run generation via the CLI with queue.enabled set to true, or trigger the command from the scheduler. Avoid running "Generate Sitemap" from the Filament page for large sites, as it runs in the web request and can hit time or memory limits.
Recommended chunk_size tuning: Default is 500. Use a smaller value (e.g. 250) if model instances are large or memory is constrained; use a larger value (e.g. 1000) to reduce query round-trips when models are small and memory is sufficient.
The package uses Pest for tests and Orchestra Testbench for Laravel application bootstrapping in a package context.
What is tested (or should be covered by contributors):
filament-sitemap-generator:generate command runs and, when queue is disabled, invokes the service and returns the correct exit code; when queue is enabled, it dispatches the job and outputs the expected message.GenerateSitemapJob with optional connection/queue from config; the job can be asserted as queued or run synchronously in tests.SitemapGeneratorService::generate() reads config, writes sitemap file(s) to the configured path, and optionally builds an index and pings search engines without failing on ping errors.max_urls_per_file, multiple part files and an index are produced; when under the limit, a single sitemap file is written to the main path.sitemap-news.xml is written in the same directory; only records with publication date within the last 48 hours are included.How to run tests:
composer test
This runs the Pest test suite (typically ./vendor/bin/pest).
Contributors: Add tests in tests/ using Pest syntax. Use the base TestCase (which extends Orchestra Testbench’s package test case) so the Laravel application and package service provider are loaded. Prefer feature tests that run the command or service and assert on file output and exit codes; add unit tests for service methods where it helps guard against regressions.
| Laravel | Filament | PHP | Status |
|---|---|---|---|
| 10.x | 3.2+ / 4.x / 5.x | 8.2+ | Supported (via composer constraints) |
| 11.x | 3.2+ / 4.x / 5.x | 8.2+ | Supported (via composer constraints) |
Composer constraints: php: ^8.2, filament/filament: ^3.2 || ^4.0 || ^5.0. Laravel version is implied by Filament and other dependencies. CI may run on a subset of these; report issues for specific version combinations.
┌─────────────────────┐
│ Filament Page │ (Settings → Sitemap → "Generate Sitemap")
│ SitemapGenerator │
└──────────┬──────────┘
│ calls generate()
▼
┌─────────────────────┐
│ Command │ filament-sitemap-generator:generate
│ GenerateSitemapCmd │ (sync) or dispatch job (queue)
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Queue Job │ GenerateSitemapJob (if queue.enabled)
│ (optional) │ handle() → service->generate()
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ SitemapGenerator │ buildStandardSitemaps → buildIndex (if needed)
│ Service │ → buildNewsSitemap (if enabled) → pingSearchEngines (if enabled)
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Spatie Sitemap │ Sitemap, SitemapIndex, Tags\Url (news, image, alternate)
│ Builder │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ XML Files │ sitemap.xml (single or index) + sitemap-1.xml, sitemap-2.xml, …
│ (single or index │ Optional: sitemap-news.xml
│ + parts) │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Optional Search │ GET Google/Bing ping URLs (main sitemap URL); try/catch per engine
│ Engine Ping │
└─────────────────────┘
Implement these on Eloquent models referenced in config('filament-sitemap-generator.models') or config('filament-sitemap-generator.news.models') to customize URL, lastmod, alternates, images, or news metadata. All methods are optional; fallbacks use config or standard attributes.
| Method | Return type | Description |
|---|---|---|
getSitemapUrl() |
string |
Canonical URL for this record. If absent, URL is built from models.*.route and the model for route(). |
getSitemapLastModified() |
\DateTimeInterface |
Last modification date for <lastmod>. If absent, updated_at is used when present. |
getAlternateUrls() |
array<string, string> |
Map of locale code => absolute URL for hreflang alternates (e.g. ['en' => 'https://...', 'fr' => 'https://...']). |
getSitemapImages() |
array<int, array{url: string, caption?: string}> |
List of image entries; each must have url; caption is optional. URLs are normalized with the configured base URL. |
getSitemapNewsTitle() |
string |
Title for Google News <title>. If absent, title or name attribute is used. Only relevant when the model is in news.models. |
getSitemapNewsPublicationDate() |
\DateTimeInterface |
Publication date for Google News. If absent, published_at, updated_at, or created_at is used. Only relevant when the model is in news.models. |
config('filament-sitemap-generator.models') with model class as key and priority, changefreq, and route (required if the model does not implement getSitemapUrl()). Use route to specify the named route used to build the URL (e.g. 'posts.show').config('filament-sitemap-generator.base_url') to a full base URL (e.g. https://example.com). All non-absolute URLs (static and model-generated) are prefixed with this value. If null, config('app.url') is used.The service is bound as a singleton in the package service provider:
$this->app->singleton(SitemapGeneratorService::class);
To use a custom implementation (e.g. to add URLs or change behavior), register your class in a service provider that runs after the package:
$this->app->singleton(SitemapGeneratorService::class, MyCustomSitemapGeneratorService::class);
Ensure your implementation is compatible with callers that type-hint SitemapGeneratorService (Filament page, command, job) or provide the same public generate(): bool contract.
You can write the sitemap to a filesystem path or to a Laravel disk.
public_path('sitemap.xml')). Configure output.mode = 'file' and output.file_path.Storage::disk($disk)->put($path, $xml). Configure output.mode = 'disk', output.disk, output.disk_path, and output.visibility ('public' or 'private').Config example:
'output' => [
'mode' => 'file',
'file_path' => public_path('sitemap.xml'),
'disk' => 'public',
'disk_path' => 'sitemap.xml',
'visibility' => 'public',
],
You can also set output mode and paths from Filament → Settings → Sitemap Settings (Output section). Values are stored in the database and override config when present.
Optional URL crawling discovers links by crawling a base URL and merges them with static and model URLs. Crawling is disabled by default.
crawl.enabled = true and crawl.url (e.g. https://example.com).max_urls_per_file).concurrency, max_count, maximum_depth, exclude_patterns (wildcards, e.g. *admin*).Config example:
'crawl' => [
'enabled' => false,
'url' => null,
'concurrency' => 10,
'max_count' => null,
'max_tags_per_sitemap' => 50000,
'exclude_patterns' => ['*admin*', '*?preview=*'],
],
You can plug in Spatie crawler behaviour via config or Filament Settings:
crawl.crawl_profile — class name implementing Spatie\Crawler\CrawlProfiles\CrawlProfile (used by Spatie as config('sitemap.crawl_profile') during the run only).crawl.should_crawl — invokable class: (UriInterface $url) => bool. If provided, only URLs for which this returns true are crawled.crawl.has_crawled — invokable class: (Url $url, ?ResponseInterface $response) => Url. Transform or filter the tag before it is added to the crawl result.Example: custom crawl profile
use Spatie\Crawler\CrawlProfiles\CrawlProfile;
use Psr\Http\Message\UriInterface;
class MyCrawlProfile extends CrawlProfile
{
public function shouldCrawl(UriInterface $url): bool
{
return true; // or custom logic
}
}
Register in config: 'crawl_profile' => MyCrawlProfile::class.
Example: should_crawl invokable class
use Psr\Http\Message\UriInterface;
class AllowOnlyBlog
{
public function __invoke(UriInterface $url): bool
{
return str_contains((string) $url, '/blog/');
}
}
Set crawl.should_crawl to AllowOnlyBlog::class.
Crawling can run with JavaScript execution so that client-rendered links are discovered. This is off by default and requires:
crawl.execute_javascript = true; optionally crawl.chrome_binary_path and crawl.node_binary_path.If execute_javascript is true but Browsershot is not installed, the plugin logs a warning and continues the crawl without JavaScript. If the JS crawl fails at runtime (e.g. Chrome not found), it retries once without JS, then continues generation. Config keys are applied temporarily and restored after the crawl so global config is not polluted.
For Google Video sitemap support, implement getSitemapVideos() on your model. Return an array of entries with at least thumbnail_loc, title, description, and either content_loc or player_loc:
use Spatie\Sitemap\Contracts\Sitemapable;
use Spatie\Sitemap\Tags\Url;
class Post extends Model implements Sitemapable
{
public function getSitemapVideos(): array
{
return [
[
'thumbnail_loc' => 'https://example.com/thumbs/1.jpg',
'title' => 'My video title',
'description' => 'Short description',
'content_loc' => 'https://example.com/videos/1.mp4',
'duration' => 120,
'publication_date' => $this->published_at?->toIso8601String(),
],
];
}
}
Optional keys include duration, expiration_date, rating, view_count, publication_date, family_friendly, restriction, tags, allow, deny, etc.
Example production-style config for a site with 500,000+ URLs: queue and scheduler enabled, chunk size tuned, file splitting at 50,000 URLs, and ping enabled.
// config/filament-sitemap-generator.php (excerpt for large-site scenario)
return [
'path' => public_path('sitemap.xml'),
'chunk_size' => 500,
'max_urls_per_file' => 50000,
'base_url' => null,
'static_urls' => [
['url' => '/', 'priority' => 1.0, 'changefreq' => 'daily'],
// ... other static entries
],
'models' => [
App\Models\Post::class => [
'priority' => 0.8,
'changefreq' => 'weekly',
'route' => 'posts.show',
],
App\Models\Category::class => [
'priority' => 0.7,
'changefreq' => 'weekly',
'route' => 'categories.show',
],
// ... other models
],
'schedule' => [
'enabled' => true,
'frequency' => 'daily',
],
'queue' => [
'enabled' => true,
'connection' => null, // default
'queue' => 'sitemaps', // dedicated queue recommended for large runs
],
'news' => [
'enabled' => true,
'publication_name' => 'Your Site Name',
'publication_language' => 'en',
'models' => [App\Models\Post::class],
],
'ping_search_engines' => [
'enabled' => true,
'engines' => ['google', 'bing'],
],
];
With this setup, php artisan filament-sitemap-generator:generate (or the daily schedule) dispatches the job to the sitemaps queue. A worker processes it; the service produces sitemap-1.xml through sitemap-N.xml (each ≤ 50,000 URLs), sitemap.xml as the index, and optionally sitemap-news.xml. Google and Bing are then pinged with the main sitemap URL. Ensure a queue worker is running (e.g. php artisan queue:work --queue=sitemaps or your production worker config).
composer test
Please see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
Please review our security policy on how to report security vulnerabilities.
The MIT License (MIT). Please see License File for more information.
Structured technical overview of the package for maintainers and contributors.
The package provides config-driven, Filament-backed XML sitemap generation for Laravel applications. It allows site owners or automation to produce sitemap(s) that comply with common limits (e.g. 50,000 URLs per file), support news/image/alternate hints, and optionally ping search engines—without writing custom generation code.
FilamentSitemapGeneratorPlugin implements Filament\Contracts\Plugin, registers a single custom page, and is attached to the default panel only inside Filament::serving() (and only if not already registered).Notification. No generation logic lives in the page.Sitemap, SitemapIndex, and Tags\Url (with addNews, addImage, addAlternate) for XML structure and writing.Class: FilamentSitemapGeneratorServiceProvider (extends Spatie\LaravelPackageTools\PackageServiceProvider)
Responsibilities:
SitemapGeneratorService as a singleton in packageRegistered().packageBooted(): registers the Filament plugin on the default panel (inside Filament::serving()), registers assets/icons (currently empty), publishes stubs when stubs/ exists, registers the schedule when enabled, and adds a testing mixin.Notable details: Schedule registration uses $this->app->booted() and resolves Schedule from the container; frequency is applied via method_exists($event, $frequency). The stubs publish loop will error if stubs/ is missing. Install command still references migrations even though the package does not appear to ship sitemap DB tables.
Class: FilamentSitemapGeneratorPlugin (Filament\Contracts\Plugin)
Responsibilities:
getId() returns 'filament-sitemap-generator'.SitemapGenerator as a Filament page in register(Panel $panel); boot() is empty.make() and get() resolve the plugin from the container or current panel.Separation: No business logic; only Filament registration.
Class: SitemapGeneratorService
Responsibilities:
generate() reads config, then calls (in order) buildStandardSitemaps(), optionally buildIndex(), optionally buildNewsSitemap(), and optionally pingSearchEngines().buildStandardSitemaps() streams URLs (static + chunked models) into Spatie Sitemap instances, flushes to sitemap-{n}.xml when the count reaches max_urls_per_file, and either writes a single file to the main path or returns part URLs for the index.buildIndex() builds a Spatie SitemapIndex from part URLs and writes it to the main path.buildNewsSitemap() builds a separate sitemap-news.xml from configured news models, filtering by publication date (last 48 hours) and using getSitemapUrl / getSitemapNewsTitle / getSitemapNewsPublicationDate (with fallbacks).pingSearchEngines() builds the main sitemap URL and GETs Google/Bing ping endpoints; failures are caught and do not affect generation.normalizeUrl() / getBaseUrl(), buildModelUrlTag(), resolveModelUrl(), and helpers for lastmod, priority/changefreq, alternates, and images. All use config('filament-sitemap-generator.*') (and config('app.url') for base); the only other external dependency is optional HttpClientFactory for ping.Design: Single public entry point (generate()), small private methods, no facades except config. Logic is centralized in the service; Filament, command, and job only call generate().
Command: GenerateSitemapCommand (filament-sitemap-generator:generate)
SitemapGeneratorService via constructor.queue.enabled is true: dispatches GenerateSitemapJob (with optional connection/queue from config), prints "Sitemap generation dispatched.", returns 0.$sitemapGenerator->generate(), prints success or error, returns 0 or 1.Job: GenerateSitemapJob (ShouldQueue)
handle(SitemapGeneratorService $sitemapGenerator) only calls $sitemapGenerator->generate(). No generation logic in the job.Separation: Command and job are thin adapters; all behavior is in the service.
| Key | Purpose |
|---|---|
path |
Main sitemap path (default public_path('sitemap.xml')) |
chunk_size |
Model query chunk size (default 500) |
max_urls_per_file |
Max URLs per file before splitting (default 50,000) |
base_url |
Override for absolute URLs (default null → app.url) |
static_urls |
List of url, priority, changefreq, optional lastmod |
models |
Map of model class => priority, changefreq, route (for URL when no getSitemapUrl) |
schedule.enabled / frequency |
Whether to schedule the command and with which frequency |
queue.enabled / connection / queue |
Whether to queue and which connection/queue |
news.enabled / publication_name / publication_language / models |
Google News sitemap |
ping_search_engines.enabled / engines |
Whether to ping and which engines (e.g. google, bing) |
FilamentSitemapGenerator class, not the service. Install command and migrations list suggest DB usage that the package does not implement. Stubs publish assumes a stubs/ directory.SitemapGenerator::runGeneration() runs in a try/catch, calls SitemapGeneratorService::generate(), then sends a success or danger notification with the exception message on failure.php artisan filament-sitemap-generator:generate runs GenerateSitemapCommand::handle().queue.enabled: command dispatches GenerateSitemapJob and exits; the worker runs the job (see 3.3).SitemapGeneratorService::generate() synchronously and returns exit code 0 or 1 with console output.GenerateSitemapJob (optionally with connection/queue from config).SitemapGeneratorService into handle(); job calls generate() once.schedule.enabled is true, the provider registers in app->booted() a schedule entry for filament-sitemap-generator:generate with the configured frequency (e.g. daily()), using the container’s Schedule instance.schedule:run (or cron) executes the command at that frequency; the command then behaves as in 3.2 (sync or queue depending on config).buildStandardSitemaps() keeps one in-memory Sitemap and a URL count.count >= max_urls_per_file, writes the current sitemap to sitemap-{partNumber}.xml in the same directory, appends its full URL to a list, creates a new Sitemap, resets count, then adds the new URL.count > 0, the single sitemap is written to the main path and an empty list is returned. If at least one part was written and the last chunk has URLs, that chunk is written as the next part and its URL is appended.getBaseUrl() plus filename (e.g. https://example.com/sitemap-1.xml).buildStandardSitemaps() returns a non-empty list of part URLs, buildIndex() is called.SitemapIndex is created, each part URL is added, and the index is written to the main path (overwriting the main sitemap.xml).sitemap-1.xml, sitemap-2.xml, etc. If there is only one "part" (no split), the implementation still writes sitemap-1.xml and then the index, so the main file is always an index when splitting occurs; single-file case is when no flush ever happens, and the only sitemap is written directly to the main path.Model::query()->chunk($chunkSize, callback) so records are not all loaded at once; only the current chunk is in memory.Sitemap is held in memory and written when the URL count hits max_urls_per_file or at the end. So at most ~50,000 Url tags in memory at once for the standard sitemap.Sitemap and written once; if news models are large, the 48-hour filter is applied per record in PHP (no DB-level date filter), so many old records can be loaded and then skipped.max_urls_per_file before flush.where('published_at', '>=', $cutoff)) would be more scalable and is not implemented.count >= max_urls_per_file (default 50,000), so no sitemap file exceeds that limit. Index file only references part URLs, so it stays small.| Feature | Support | Notes |
|---|---|---|
| Standard sitemap | Yes | Static URLs + models; optional splitting + index |
| lastmod | Yes | Static: lastmod in config. Models: getSitemapLastModified() or updated_at |
| changefreq & priority | Yes | From config per static entry and per model config |
| Alternate URLs | Yes | Model method getAlternateUrls() → [locale => url]; applied via Spatie addAlternate |
| Google News | Yes | Separate sitemap-news.xml; 48-hour window; publication name/language from config; title/date from model methods or attributes |
| Image sitemap | Yes | Model method getSitemapImages() → list of url/caption; applied via Spatie addImage |
| Search engine ping | Yes | Google and Bing; main sitemap URL only (index or single file); failures caught |
Model contracts (optional): getSitemapUrl(), getSitemapLastModified(), getAlternateUrls(), getSitemapImages(), getSitemapNewsTitle(), getSitemapNewsPublicationDate(). Fallbacks use attributes like title, updated_at, published_at, etc.
daily, hourly).sitemap-news.xml in the same dir as main path).sitemap-1.xml, sitemap-2.xml, …).FilamentSitemapGenerator facade resolves to an empty class; it does not delegate to SitemapGeneratorService, so it is misleading and unused.Filesystem::files(__DIR__ . '/../stubs/') without checking the directory exists; will throw if stubs/ is missing.where('date_column', '>=', $cutoff) on the query, so large tables waste work and memory.sitemap-news.xml is not pinged.Schedule from the container; custom scheduler setups may not see the entry.FilamentSitemapGenerator at SitemapGeneratorService (or a small wrapper) so FilamentSitemapGenerator::generate() works and is documented.is_dir(__DIR__ . '/../stubs/') before iterating, or remove the publish if no stubs are shipped.published_at) and apply where($column, '>=', $cutoff) in the news query so only recent rows are loaded.SitemapGenerating, SitemapGenerated) with path and part count so apps can log, invalidate caches, or extend.SitemapUrlProvider) for models so IDEs and static analysis can rely on a clear contract alongside the current convention-based methods.