Sitemaps
Generate an XML sitemap with reciprocal hreflang alternates for your sluggable models.
Bind a PolyslugUrlResolver — the package cannot know your URL
structure, so this one class is yours to write — and register the types:
use Polyslug\Contracts\PolyslugUrlResolver;
use Polyslug\Contracts\Sluggable;
$this->app->bind(PolyslugUrlResolver::class, fn () => new class implements PolyslugUrlResolver
{
public function url(Sluggable $model, string $locale): string
{
return route('pages.show', ['locale' => $locale, 'page' => $model->polyslugRouteKeyForLocale($locale)]);
}
});
// config/polyslug.php
'sitemap' => ['types' => [\App\Models\Page::class, \App\Models\Article::class]],
php artisan polyslug:sitemap --path=public/sitemap.xml
Without --path the XML goes to standard output, which is convenient for piping or for
inspecting a few entries during development.
To keep the file current, schedule the command and guard it against overlapping runs:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('polyslug:sitemap --path=public/sitemap.xml')
->daily()
->withoutOverlapping()
->onOneServer();
withoutOverlapping() keeps a slow run on a large table from starting twice, and
onOneServer() keeps a multi-server deployment from writing the file on every host; the
latter needs a cache store that supports atomic locks (Redis, database, DynamoDB or
Memcached).
What it includes
-
Only routable models and locales. Anything you hide via
polyslugIsRoutable()stays out, and a model with no routable locale produces no entry at all. -
Nothing that would answer
410or301. A record that reportspolyslugIsGone(), and a superseded one whose successor is visible, are left out — the same precedence the canonical middleware applies — so the sitemap never submits an address the site itself refuses or redirects. -
One
<url>per address, not per record. A record served under three locales produces three<url>entries, one per address. That is what search engines count as submitted: an<xhtml:link>is an annotation about an address, never a submission of it, so a locale that appeared only as an annotation was never announced at all. -
Reciprocal hreflang alternates,
x-defaultincluded. Every one of those entries carries the same complete alternate set — one<xhtml:link>per routable locale, one forx-default, and a self-reference — because reciprocity is a property of the record rather than of the address. The set comes fromhreflangLinks(), the one method that also feeds the page<head>, so the sitemap and the head cannot disagree about which addresses a record has.x-defaultis an alternate and never a<loc>: it is a second key over an address already in the set, and submitting it too would list that URL twice. See Multilingual slugs and hreflang.Changed in 0.15.0 and again sinceIn 0.15.0 the command stopped building its alternates in its own loop, which had made
<loc>take the alphabetically first locale while the head announcedx-default, and had leftx-defaultout of the sitemap entirely.Since then the entry itself changed shape: a record used to produce exactly one
<url>, whose<loc>was thex-defaultaddress, with every other locale appearing only as an annotation. At N locales that left (N−1)/N of the addresses unsubmitted. Both are regenerated by the next run; nothing in your code changes, and a sitemap file written by an older version is not wrong, only shorter than it should be. -
<lastmod>when a model offers one, and nothing else.<priority>and<changefreq>are documented as ignored, so the command emits neither.lastmodis the hint that is still read — while it stays accurate. ImplementpolyslugLastModified()to supply it:use DateTimeInterface;public function polyslugLastModified(): ?DateTimeInterface{return $this->updated_at;}It returns
nullby default, on purpose. A timestamp that moves on every write — a view counter, a cached column, a nightly re-import that touches every row — turns the field into noise, and the documented response is to disregard it for the whole site rather than for the one row. Wire it up whereupdated_atreally does track the content, and leave it alone where it does not. The method lives onHasPolyslugrather than on theSluggablecontract, so a hand-written implementation keeps working unchanged. -
Registered types only. Entries in
polyslug.sitemap.typesthat are not Eloquent models implementingSluggableare skipped rather than failing the run. -
A type the resolver cannot address costs that type, not the document.
polyslug.sitemap.typesis configuration, and being aSluggablemodel says nothing about whether anything routes it: a model that never had a route, or one whose route was renamed while the config stayed, is ordinary. Those records are skipped and the rest of the sitemap is written.Say it without throwing by adding
canAddress()to your resolver — optional, so an existing implementation needs no change:public function canAddress(Sluggable $model): bool{return ! $model instanceof Milestone; // carries a slug, nothing routes it}A declared refusal is silent. A resolver that throws instead is also survived — it has to be, because a resolver can fail for reasons nobody declared — but those records are counted and named at the end of the run. Only one of the two is a decision, and the output says which happened.
Why the run no longer stopsIt used to. One throw ended the whole command: no
<urlset>, no file, a red scheduled job — and a red scheduled job does not replace the file it was going to write, so the previous sitemap stayed where it was and aged silently. From the outside that is indistinguishable from a sitemap being kept current.
Large sites: splitting and the index
The sitemap protocol caps one file at 50,000 URLs and 50 MB uncompressed. A file past either limit is rejected whole, not truncated.
You do not have to notice that. With --path, the command writes numbered parts beside it
and puts a <sitemapindex> at --path itself as soon as either ceiling is reached:
public/sitemap.xml <- the index, this is the URL you submit
public/sitemap-1.xml <- 50,000 URLs
public/sitemap-2.xml <- the rest
While everything fits, --path gets one ordinary <urlset> document and no index, exactly
as before.
An index has to name each part by absolute URL, so the command needs to know where the
files are served from. It takes app.url, or --base-url if the sitemaps live somewhere
else (a CDN, a subdirectory):
php artisan polyslug:sitemap --path=public/sitemap.xml --base-url=https://cdn.example.com
If it needs an index and can determine neither, it fails rather than writing one with relative locations — an invalid index takes the whole submission down, where a missing one only delays it.
Both ceilings are configurable under polyslug.sitemap.max_urls and
polyslug.sitemap.max_bytes. Lower them if a CDN or a search console you use wants smaller
files. Raising them past the protocol produces documents engines reject.
Memory
Rows are read in keyset chunks, so a large table is never loaded at once. Each part is
written as soon as it is full, so with --path the peak is one part, not the whole
document. Without --path there is nowhere to flush to: the document is assembled in one
piece and has to fit — roughly 40 MB of peak memory for 55,000 entries. That matters more
than it sounds: the naive version of this command is the one that works in development and
gets killed by the memory limit on the table that actually needed a sitemap.
When the resolver is missing
Without a bound PolyslugUrlResolver the command cannot build a single URL, so it prints
an error naming the contract and exits non-zero rather than writing an empty sitemap over
a good one.
The same resolver powers short links and the
laravel/head integration — bind it once. The URL resolver
covers it in full, including what each feature does when it is missing.