Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Php Sitemap Laravel Package

rumenx/php-sitemap

Rumenx PHP Sitemap generates standards-compliant XML sitemaps and sitemap indexes in PHP. Build URL sets, add images, videos, news, and hreflang alternates, then output plain or gzipped XML through a simple, extensible API for small sites or large dynamic applications.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and confirm what actually gets registered — and take the security patch.

composer require rumenx/php-sitemap:^1.0.4

v1.0.4 is a security patch, not a feature release: it rejects path traversal in Sitemap's store filename. Two consequences for you as a new integrator:

  • If your lock file resolves to 1.0.3 or below, run composer update rumenx/php-sitemap before writing any code. Nothing else about the API changes, but a package that accepts a filename argument and didn't sanitize it is a package you don't want to onboard on a stale version.
  • The changelog just told you something the README didn't emphasize: there is a store($filename)-style API that takes a filename from the caller. Open vendor/rumenx/php-sitemap/src/ and read that method's implementation and test before you decide whether to use it or writeToFile() with your own hardcoded path. Also check how the fix behaves — does it throw on ../, silently strip it, or resolve against a base directory? You need to know which, because your error handling depends on it.

Because this is a framework-agnostic core with a thin Laravel wrapper, don't read the README top-to-bottom — it documents the standalone PHP API first. Jump to the Laravel section, then open the service provider. That file tells you the truth about:

  • the container binding / facade alias (if any),
  • which config file is published (config/sitemap.php or similar),
  • whether an artisan command is registered,
  • whether any routes are auto-registered (many sitemap packages do this — check before you add your own).
php artisan vendor:publish --provider="Rumenx\Sitemap\..." --tag=config
php artisan route:list | grep -i sitemap
php artisan list | grep -i sitemap

First use case: the boring one. Get a valid /sitemap.xml serving your static marketing pages before you touch models. Resolve the generator from the container, add three URLs, and dump it to a response. Verify the output in a browser and with curl — you want to confirm the Content-Type is application/xml (or text/xml) and that you didn't accidentally get an HTML error page with a 200 status.

Where to look first, in order:

  1. The published config — it usually controls caching, gzip, the XSL stylesheet, and URL limits.
  2. The generator class's public methods — addUrl/add/append variants, store, writeToFile, render, and any index-building helper. Note which of these accept a filename; that's your trust boundary.
  3. The tests in vendor/rumenx/php-sitemap/tests/ — with 0 dependents and a small ecosystem, the test suite is your best documentation. Start with the test added alongside the traversal fix; it pins down the expected rejection behavior better than any changelog line.

Pin the version. Use "^1.0.4" with a committed composer.lock, not "*". With zero dependents you're an early adopter — and a 1.0.4 security patch three days into a 1.0.x line is a signal to read every changelog, not to stop reading them.

Implementation Patterns

Pattern 1: Generate to disk in a job, serve statically

The single most important architectural decision: do not generate the sitemap on every request. Even a "cached" generator pays XML serialization cost on cache miss, and a crawler hitting /sitemap.xml repeatedly can turn that into a thundering herd.

// app/Jobs/GenerateSitemap.php
public function handle(SitemapGenerator $sitemap): void
{
    $sitemap->reset(); // if available — see Gotchas

    Article::query()->where('published', true)
        ->select(['slug', 'updated_at'])
        ->chunk(1000, function ($articles) use ($sitemap) {
            foreach ($articles as $a) {
                $sitemap->addUrl(
                    url("/articles/{$a->slug}"),
                    $a->updated_at,
                    'weekly',
                    '0.8'
                );
            }
        });

    // Prefer an explicit path you control over a caller-supplied filename.
    // See Gotchas re: the v1.0.4 traversal fix.
    $sitemap->writeToFile(public_path('sitemap.xml'));
}

Schedule it nightly, and also dispatch it from model observers (saved, deleted) with a debounce (Cache::lock) so a bulk import doesn't queue 5,000 jobs.

Serving from public/ means nginx/Apache handles it — no PHP, no framework boot, no cache stampede. Add the reverse-proxy cache headers yourself; the package can't do that for you once it's a static file.

Pattern 2: Route + response for dynamic/multi-tenant sitemaps

When the sitemap genuinely must be dynamic (per-tenant domains, or a sitemap that must reflect data written seconds ago), generate into a Response and cache the rendered string, not the object:

Route::get('/sitemap.xml', function () {
    $xml = Cache::remember('sitemap:xml', now()->addHour(), function () {
        return app(SitemapGenerator::class)
            ->addUrl(route('home'), now(), 'daily', '1.0')
            // ...
            ->render(); // or ->toString() / ->toXml()
    });

    return response($xml, 200, ['Content-Type' => 'application/xml; charset=UTF-8']);
});

Cache the string. Caching the generator object is a trap — it's mutable and accumulates URLs across resolutions if it's a singleton binding.

Pattern 3: Sitemap index once you outgrow one file

Any site with more than 50,000 URLs or a file over 50 MB uncompressed must split. The standard shape:

  • /sitemap.xml → an index containing <sitemap><loc> entries only (no <url> entries — this is the most common spec violation).
  • /sitemap-articles-1.xml, -2.xml, … → real URL sets.

Partition by a stable, monotonic key (primary key ranges), not by offset on an unordered query — otherwise URLs shuffle between child sitemaps on every regeneration and crawlers re-fetch everything. Chunk by ID ranges:

$max = Article::max('id');
foreach (range(0, $max, 10000) as $start) {
    // build one child sitemap for ids [$start, $start + 10000)
}

With store($filename) this is also where the filename comes from a loop variable. Generate those segment names from integers you computed — never from anything a request could influence — and you stay clear of the traversal class of bug entirely.

Pattern 4: Model-driven URL sources

Put the "how does this model become a sitemap entry" logic on the model, not in the generator call site. An interface keeps it testable and lets you iterate a heterogeneous list:

interface SitemapSource {
    public function sitemapUrls(): iterable; // yields [loc, lastmod, changefreq, priority]
}

Then a single command loops registered sources. This is also where you centralize the "is this row indexable?" check — a noindex page in your sitemap is a Search Console warning you don't need.

Pattern 5: Multilingual and multi-domain

If the package exposes hreflang alternates, wire them from your translation tables; if it doesn't, add the <xhtml:link rel="alternate" hreflang="…"> elements by post-processing the XML — but remember to declare xmlns:xhtml on the <urlset>. If you can't declare the namespace through the package's API, that's a hard blocker; check before committing to it.

For multi-tenant, one index per domain is cleaner than one mega-sitemap: https://{tenant}.example.com/sitemap.xml, generated by a job scoped to that tenant. Never emit URLs for one host inside another host's sitemap.

Pattern 6: Test the XML, not the generator

Snapshot the rendered output in a feature test and assert on the DOM:

$xml = $this->get('/sitemap.xml')->getContent();
$dom = new DOMDocument();
$this->assertTrue($dom->loadXML($xml)); // catches unescaped ampersands
$this->assertSame('urlset', $dom->documentElement->localName);

loadXML returning false is the single most valuable assertion you can write here — it catches the escaping bug class instantly. Add a second test that asserts your generation job writes only under public_path() regardless of inputs; that's the regression guard for the v1.0.4 bug class.

Gotchas and Tips

store() takes a filename — treat it as untrusted input until proven otherwise. v1.0.4 fixes a path traversal in Sitemap::store. Read the fix's test to learn the exact contract (throw? sanitize? strip separators?), then wrap the call in your own SitemapService that accepts only an enum or an integer segment, never a raw string from a request, a queue payload, or a tenant slug. If you need a caller-influenced name, validate against ^[a-z0-9-]+$ and append .xml yourself. A future regression here is arbitrary file write across your filesystem — this is the one API in the package worth armouring.

Upgrade deliberately, verify behaviourally. 1.0.4 is non-breaking on the surface, but "rejects" could mean a thrown exception where you previously got a file. If you already ship on 1.0.3 and your store() call happens to pass something like sitemaps/2026/03.xml, confirm after upgrading that it still writes where you expect — a stricter path check can also reject legitimate subdirectories. Run your generation job in staging and diff the output directories.

Escape everything, especially query strings. ?page=2&sort=new becomes invalid XML unless the ampersand is &amp;. Most generators escape loc for you; almost none escape a lastmod you've formatted yourself, or an image/video extension block. Run loadXML in a test.

lastmod must be W3C datetime, and lying about it is worse than omitting it. Emit 2026-03-04T10:15:00+00:00 or 2026-03-04. If you set lastmod to now() on every regeneration, Google learns to ignore your lastmod entirely — it's a documented behavior. Use the model's real updated_at. Omit the field if you don't have a truthful value.

priority and changefreq are ignored by Google. They're harmless, and Bing/Yandex may still read them, but don't spend design effort on per-page priority tiers. Spend it on lastmod accuracy and keeping 404s/dead URLs out.

URLs must be absolute and canonical. url('/articles/x') is right; /articles/x is not. Make sure you emit the canonical host — not whatever APP_URL happens to be in the queue worker's environment. In queued jobs, APP_URL is not a per-request value; if you rely on url(), a misconfigured worker will silently publish a sitemap full of http://localhost.

Trailing slashes matter. If /articles/x and /articles/x/ both resolve, pick one and be consistent with what your <link rel="canonical"> says. A mismatch between sitemap and canonical is a real, common, and confusing Search Console error.

The generator is likely stateful. If it's bound as a singleton, calling generate twice in one process (e.g. a loop over tenants) will append to the previous run's URL list. Look for a reset()/clear(), or bind it as a non-shared instance ($this->app->bind rather than singleton) in a service provider of your own.

50,000 URLs / 50 MB uncompressed are hard limits. Gzip doesn't exempt you — the limit applies pre-compression. Exceeding it doesn't error usefully; Google just stops reading. Check the file size in your generation job and fail loudly / alert if it crosses ~45 MB.

Gzip: serve with the right headers or not at all. A sitemap.xml.gz served without Content-Type: application/gzip (and without the .gz suffix in the URL) will fail. Also: if you gzip, the index should reference the .gz filenames. Don't gzip on the fly per-request if it's a static file — let the web server do it.

robots.txt is still your job. The package won't add Sitemap: https://example.com/sitemap.xml for you in most designs. Add it, and make sure it points at the index, not a child sitemap.

The XSL stylesheet is a footgun in production. Human-readable sitemaps are lovely in dev. The stylesheet is fetched by the browser from the URL you declare; if that path 404s in production you get a broken-looking page for humans and a perfectly fine sitemap for crawlers. Either ship the XSL file or disable it in production config.

Extension points to look for before forking: the URL-entry value object (usually the easiest place to inject custom namespaces), the writer/renderer (swap for a streaming writer on huge sets), and the config's URL-limit / caching knobs. If the package has a SitemapWriter interface, implement a StreamingWriter that writes with XMLWriter to a temp file and renames atomically — this keeps memory flat regardless of URL count, which the default in-memory string builder will not.

Write atomically. writeToFile(public_path('sitemap.xml')) while a crawler is mid-download gives truncated XML. Write to sitemap.xml.tmp and rename() — a same-filesystem rename is atomic on Linux. The same discipline applies to store(): know whether it writes in place or stages, and wrap externally if it doesn't.

Debugging checklist when the sitemap "doesn't work" in Search Console:

  1. curl -I the URL — status and content type first.
  2. xmllint --noout sitemap.xml — well-formedness.
  3. Confirm the index contains only <sitemap> entries.
  4. Check lastmod values against the DB, not against now().
  5. Check the host in every <loc> matches the property you verified in Search Console (www vs apex vs http).
  6. On a generation failure after any upgrade, log the resolved output path — a tightened filename check surfaces as a missing file, not an obvious exception, in half the designs.

Version risk. 1,340 stars is respectable, but zero dependents plus a security patch in the first weeks of 1.0.x means you're among the first integrators and the API is still settling. Keep the integration surface narrow — one job, one route, one config file — so that a change in a patch release is a 20-line fix rather than a refactor. Wrap the generator behind your own SitemapService so the package's API never leaks into controllers, and so the store() filename is constructed in exactly one reviewed place.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource