Skip to main content

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 410 or 301. A record that reports polyslugIsGone(), 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-default included. Every one of those entries carries the same complete alternate set — one <xhtml:link> per routable locale, one for x-default, and a self-reference — because reciprocity is a property of the record rather than of the address. The set comes from hreflangLinks(), the one method that also feeds the page <head>, so the sitemap and the head cannot disagree about which addresses a record has. x-default is 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 since

    In 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 announced x-default, and had left x-default out of the sitemap entirely.

    Since then the entry itself changed shape: a record used to produce exactly one <url>, whose <loc> was the x-default address, 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. lastmod is the hint that is still read — while it stays accurate. Implement polyslugLastModified() to supply it:

    use DateTimeInterface;

    public function polyslugLastModified(): ?DateTimeInterface
    {
    return $this->updated_at;
    }

    It returns null by 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 where updated_at really does track the content, and leave it alone where it does not. The method lives on HasPolyslug rather than on the Sluggable contract, so a hand-written implementation keeps working unchanged.

  • Registered types only. Entries in polyslug.sitemap.types that are not Eloquent models implementing Sluggable are skipped rather than failing the run.

  • A type the resolver cannot address costs that type, not the document. polyslug.sitemap.types is configuration, and being a Sluggable model 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 stops

    It 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.