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. A record whose successors come back to it, one that names itself included, is served at its own address and therefore listed. -
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. An address served under two codes is submitted once, with both codes among its alternates. -
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. -
A database error ends the run, and the last sitemap stays. Only what the resolver throws is counted as a skip. A database error is not a record the resolver cannot address, wherever it is raised, so it fails the run with a non-zero exit code. Every file is written under a temporary name first and renamed into place once the whole set is complete, so a run that fails, part-way through a split set included, publishes nothing and the previous sitemap is still served. A replaced file keeps its permissions.
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.
When a later run needs fewer parts, or fits one file again, it removes the parts the index it replaces named and the new set no longer writes, once every new file is in place. A file that index did not name stays, whatever it is called, and a run that fails removes nothing.
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.
When the records of a part carry a <lastmod>, the index names the latest of them for that
part, so a search engine can tell which parts changed since it last read them.
The sitemap as a service
The command covers one sitemap for the whole application, written to files. For anything else,
call the service behind it, Polyslug\Sitemaps\Sitemap. It takes the sources of its records,
the storage its parts are kept in and the root they are served under. That gives a multi-tenant
application one sitemap per tenant, parts kept in a table instead of on a disk, and a rebuild
of only the part that holds a changed record:
use Polyslug\Contracts\PolyslugUrlResolver;
use Polyslug\Sitemaps\DatabaseSitemapStorage;
use Polyslug\Sitemaps\Sitemap;
use Polyslug\Sitemaps\SluggableSource;
$pages = new SluggableSource('pages', [Page::class], app(PolyslugUrlResolver::class));
$sitemap = new Sitemap(
sources: [$pages],
storage: new DatabaseSitemapStorage('sitemap_parts', ['shop_id' => $shop->id]),
root: 'https://'.$shop->domain.'/sitemaps',
);
$sitemap->build(); // every part of every source
$sitemap->rebuild('pages', [$pages->key($page)]); // the part that holds $page
The ceilings come from polyslug.sitemap.max_urls and polyslug.sitemap.max_bytes unless
the constructor is given maxUrls and maxBytes.
Parts cut by key
Each source is cut into parts by the keys of its records, compared byte by byte. A part holds
the records from the key it starts at up to the key the next part starts at, within both
ceilings, and the addresses of one record always share a part. Because a part is a range of
keys rather than a position, rebuild() reads only the range of the part that holds each key
it is given: a new record lands in the part whose range holds its key, and a removed one leaves
its part. A part that outgrows a ceiling, or empties while others of its source remain, has
its source cut anew.
polyslug:sitemap --path is this service with one source over files, and it writes the same
parts the service writes into any other storage.
Sources
SluggableSource lists sluggable models and applies everything this page describes: the
resolution gate, gone and superseded records, canAddress() and <lastmod>. Its key for a
model comes from key($model), built from the position of the model's type and its primary
key. An integer key is padded to twenty digits, so its byte order is its numeric order.
Anything else implements Polyslug\Contracts\SitemapSource: a name, which names the parts
(products writes products-1.xml), and the records whose keys lie in a range, in the byte
order of their keys. A read model with an address scheme of its own needs no Sluggable
model:
use Polyslug\Contracts\SitemapSource;
use Polyslug\Sitemaps\SitemapRecord;
final class ProductPages implements SitemapSource
{
public function __construct(private Shop $shop) {}
public function name(): string
{
return 'products';
}
public function records(string $from = '', ?string $until = null): iterable
{
$pages = ProductPage::query()
->where('shop_id', $this->shop->id)
->where('key', '>=', $from)
->when($until !== null, fn ($query) => $query->where('key', '<', $until))
->orderBy('key');
foreach ($pages->cursor() as $page) {
// every address by locale or hreflang code, x-default among them where there is one
yield new SitemapRecord($page->key, $page->addresses, $page->changed_at);
}
}
}
A key never changes, and the database has to sort the key column byte by byte, as a ULID or a
UUID sorts everywhere and any column with a binary collation does (COLLATE "C" on
PostgreSQL). Keys that come back in another order are noticed and leave the parts of that
source without known ranges, so every rebuild of it reads the whole source. The result stays
correct, and each rebuild costs a whole read.
Storage
FileSitemapStorage is what the command uses: the parts beside a path and the index at the
path, or the only part alone at the path. Files keep no record of where each part starts, so
rebuild() writes a sitemap in files whole.
DatabaseSitemapStorage keeps a row for each part in a table of yours, so every node of a
deployment serves the same sitemap without a shared disk:
Schema::create('sitemap_parts', function (Blueprint $table) {
$table->id();
$table->foreignId('shop_id');
$table->string('source');
$table->unsignedInteger('number');
$table->text('starts_at')->nullable();
$table->unsignedInteger('urls');
$table->string('lastmod')->nullable();
$table->longText('body');
$table->unique(['shop_id', 'source', 'number']);
});
The scope, ['shop_id' => $shop->id] above, is matched on every read and written into every
new row. Each build and rebuild is one transaction, and it reads the parts it plans from with
FOR UPDATE, so two writes of one sitemap that already has parts wait for each other. Write a
new sitemap's first parts from one process at a time, a queued job with WithoutOverlapping
for one.
Any other store implements Polyslug\Contracts\SitemapStorage.
Serving the parts from a table
Route::get('/sitemap.xml', fn () => response($sitemap->index())
->header('Content-Type', 'application/xml'));
Route::get('/sitemaps/{name}', fn (string $name) => response($sitemap->document($name) ?? abort(404))
->header('Content-Type', 'application/xml'));
index() names every part under the root, pages-1.xml, pages-2.xml and so on, each with
its <lastmod>. document() answers with the part served under a name, or null. A sitemap
whose parts need an index and that has no root refuses with CouldNotBuildSitemap, since a
relative location is invalid in an index.
Memory
Rows are read in keyset chunks of 500, so a large table is never loaded at once, and each
chunk arrives with its slug rows eager-loaded and its tokens fetched in one query. The chunks
follow the primary key whatever order a polyslugResolveQuery() applies, so every row the
gate lets through is read exactly once. A run costs
a few queries per 500 records rather than several per record. Each part is written as soon as
it is full, so with --path the peak is one part, not the whole document. The token memo
of a long-running process keeps at most 10,000 entries per memo and lets go of the older half
beyond that. 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.