rankbeam/laravel-seo
Rankbeam Laravel SEO is a lightweight package for managing search and social metadata in Laravel applications. It helps set dynamic titles, meta descriptions, canonical URLs, Open Graph tags, and Twitter cards for better search visibility and link previews.
This package generates sitemaps (and sitemap indexes) for a Laravel application, splitting large URL sets into shards so you can serve millions of URLs without blowing memory or hitting the 50k-URLs-per-file limit.
Minimal setup:
composer require <vendor>/<package> then php artisan vendor:publish --tag="sitemap-config".config/sitemap.php first — this is where the output disk/path, shard size, and the URL sources (model queries, static routes, custom URL providers) are defined. Everything you'll debug later lives here.// config/sitemap.php
'urls' => [
fn () => App\Models\Post::query()
->where('published', true)
->get()
->map(fn ($post) => url("/blog/{$post->slug}")),
],
php artisan sitemap:generate. Check the configured disk (storage/app/public/sitemap/ by default in most setups) for sitemap.xml (the index) plus sitemap-1.xml, sitemap-2.xml, …// routes/console.php
Schedule::command('sitemap:generate')->dailyAt('02:00');
// routes/web.php
Route::get('/sitemap.xml', fn () => response()->file(
storage_path('app/public/sitemap/sitemap.xml')
));
If you're on a multi-shard setup, the index file is the only thing search engines need to be pointed at; the shards must be reachable at the URLs the index lists.
Sharded generation as the default, not the exception. Configure a shard size (shard_size, typically 10k–50k URLs) up front even if you're small today. Growing past the limit later silently changes your file layout, and the URLs baked into the index change with it.
Regenerate-on-write vs. scheduled rebuild. For sites with modest churn, a nightly scheduled rebuild is simplest and pairs well with cache-busting on the route. For high-churn content, use model events to queue a debounced regeneration:
// app/Observers/PostObserver.php
public function saved(Post $post): void
{
Cache::put('sitemap:dirty', true, now()->addMinutes(10));
}
Then have the scheduled command no-op when the flag isn't set. This avoids ten regenerations for a bulk import.
Split providers by content type. Rather than one giant closure, register one entry per content type (posts, products, categories, static pages). It keeps the config readable, lets you disable a provider without touching the rest, and makes the shard ordering predictable when you're debugging which shard a URL landed in.
Serve through the web server, not PHP, in production. Generate to public/ (or a symlinked disk) and let nginx/Apache serve the XML with Content-Type: application/xml. Routing every sitemap request through Laravel is fine for previews, wasteful for crawlers that fetch shards in parallel.
Preview before promoting. Generate into a staging path, diff the index against the live one, then swap. This is exactly the workflow the v3.21.1 shard-boundary fix protects — but see the gotcha below about previews and concurrency.
Compress at generation time if your host allows it. Gzipping shards (sitemap-1.xml.gz) roughly halves crawl bandwidth; make sure the index lists the .gz names and that your web server sets Content-Encoding correctly. Never serve a .gz with application/xml and no encoding header — crawlers will reject it.
Keep lastmod honest. Only emit lastmod from a column you actually maintain (updated_at is fine; created_at is not). Fake or always-now timestamps train crawlers to ignore the field.
Shard boundaries are consistent, but data is not a snapshot. Since v3.21.1, one generation shares a single set of shard boundaries across all its writes and releases them after success or failure. That fixes the class of bug where a delete mid-run produced an index referencing shard files that were never written (or dropped surviving URLs). What it does not give you is point-in-time consistency: model values and inclusion checks are evaluated live per shard. A post published while shard 3 is being written may appear in shard 3 but not in shard 1's count, and a post deleted between shards simply won't be emitted. Treat the output as "a good approximation generated over a few seconds," not as a database snapshot. If you need strict consistency, generate from a read replica with a fixed transaction, or accept eventual consistency and rebuild nightly.
Don't pin a preview and expect it to hold. Standalone preview generations can no longer pin shard boundaries that later exports rely on — the boundary set is scoped to a single generation and released when it finishes. A stale preview artefact is not a lock; a concurrent sitemap:generate will happily use its own boundaries. If you need to serialise generation, use WithoutOverlapping on the scheduled command:
Schedule::command('sitemap:generate')->daily()->withoutOverlapping();
Stale shards are the classic failure mode. If a previous run produced 12 shards and this run produces 9, shards 10–12 linger on disk. The index won't reference them, so crawlers are fine — but your disk fills up and a confused human may "fix" the wrong file. Either generate into a clean directory per run and swap atomically, or prune files not referenced by the freshly written index.
Memory blows up in the collection, not the writer. Post::all() inside a provider is the usual culprit. Use cursor()/lazy() and select() only the columns you need (typically slug and updated_at). The shard writer is streaming; your Eloquent query is not.
Queue timeouts look like corrupt sitemaps. If you dispatch generation to a queue, a worker killed mid-run leaves a partial shard set. The boundary release on failure means the next run starts clean, but the current output directory may be incomplete. Always generate to a temp path and promote on success if generation runs in a worker.
Check which disk you're actually writing to. storage:link must exist for the public disk, and APP_URL must match the host crawlers will fetch. A sitemap full of http://localhost URLs is the single most common post-deploy bug.
Verify the index, not just the shards. A quick sanity check after each deploy:
curl -s https://example.com/sitemap.xml | grep -o '<loc>[^<]*' | wc -l
Compare that count to the number of shard files on disk and to SELECT COUNT(*) on your source query. A mismatch of a handful is normal (live data); a mismatch of thousands means a provider is silently failing.
Extension point: custom URL providers. If you need per-URL changefreq/priority or hreflang alternates, implement them in a provider that returns fully-formed entries rather than post-processing the generated XML. Post-processing breaks the moment sharding changes.
No migration or config change was required for v3.21.1 — it's a correctness fix for concurrent deletes during generation. Upgrading is safe; the only behavioural difference you'll notice is that index/shard mismatches stop appearing when content is edited during a run.
How can I help you explore Laravel packages today?