Appearance
Sitemap Generation
Flow ID: SY-05 | Module(s): job, eshop, blog, category | Complexity: Medium Last Updated: 2026-07-21 — 4.119.0 release resync: corrected the #459 fix version to 4.119.0; added
product_codesto the Data Model
Business Overview
The AdvGenerateSitemaps job generates XML sitemaps for search engine crawlers. It streams URLs for all public-facing content -- products, categories, vendors, vendor product lines, vendor-category intersections, blog articles, and CMS pages -- through a bounded in-memory buffer, flushing chunked sitemap files (up to 25,000 URLs per file) plus a sitemap index to the dedicated storage('sitemap') disk under the sitemap/ prefix as soon as the buffer fills. This keeps peak memory flat regardless of catalogue size -- large tenants previously exceeded the CLI's memory/time budget building one big in-memory URL array before writing anything.
The sitemap index is a static file written by the cron alongside the chunks, not a controller endpoint. For local-disk tenants nginx serves all files (chunks + index) directly from public/sitemap/; for K8s tenants the same files live on S3 and are served via a CDN edge mounted at /sitemap/. Crawlers always hit the same registered URL /sitemap/sitemap_index.xml.
The job runs daily at 02:30 by default in its own dedicated sitemap queue (moved off the locked core queue so it can use a larger grace time for the streaming run).
Architecture
Nightly cron (no per-request work — no controller in the path):
AdvGenerateSitemaps::executeCommand()
|
+--> @set_time_limit(0) / @ini_set('memory_limit', '1G') raise CLI limits for the streaming run
+--> storage('sitemap')->listContents / ->delete(each) wipe the previous run's chunks + index
|
+--> addUrls([homepage, vendors page]) static entry points
+--> addUrls(getCategories()) product category tree (4 levels deep); also populates $publishCategories
+--> addUrls(getVendors()) vendors + exclusive pages + product lines + vendor-category pages
+--> addUrls(getBlog()) published blog articles
+--> addUrls(getPages()) CMS static pages
|
+--> getTopSaleProductIdsForSiteMap(500) one upfront query resolving top-seller priority (1.0)
+--> loop: getProductsForSiteMapBatch(afterId, 25000) keyset (id > afterId) batches until a short batch ends the loop
| +--> addUrls(batchUrls) buffer each batch's URLs
|
+--> addUrls() buffers URLs and, once the buffer reaches CHUNK_SIZE (25000), calls flushChunk() immediately
| +--> siteMap(chunk) generate XML per chunk
| +--> ->put(sitemap/sitemap-N.xml) write the chunk
| +--> base_url(path) → $chunkUrls[] canonical-host URL for the index
|
+--> flushChunk(residual buffer) flush whatever's left after all getters/batches run
+--> siteMapIndex($chunkUrls) generate the index XML
+--> ->put(sitemap/sitemap_index.xml) write the index alongside the chunks
Request path (crawler hits the registered URL):
GET /sitemap/sitemap_index.xml
+--> Local-disk tenants → nginx serves public/sitemap/sitemap_index.xml directly
+--> K8s tenants → CDN edge serves it from S3 at the same pathKey Files
| File | Role |
|---|---|
ecommercen/job/libraries/AdvGenerateSitemaps.php | Job implementation |
application/modules/job/libraries/GenerateSitemaps.php | Client-overridable subclass |
ecommercen/helpers/xml_helper.php | siteMap() and siteMapIndex() XML generators |
ecommercen/eshop/models/Adv_product_category_model.php | Category tree and vendor-category queries |
ecommercen/eshop/models/Adv_vendors_model.php | Vendor sitemap queries |
ecommercen/eshop/models/Adv_lines_model.php | Vendor product lines |
ecommercen/eshop/models/Adv_product_model.php | getTopSaleProductIdsForSiteMap() (upfront top-seller IDs) and getProductsForSiteMapBatch() (keyset-paginated product rows); the older getProductsForSiteMap() still exists but is now entirely unused |
ecommercen/blog/models/Adv_blog_model.php | Blog article list |
ecommercen/category/models/Adv_categories_model.php | CMS pages list |
src/Storage/Storage.php | League Flysystem wrapper used to read/write chunks on local or S3 |
application/config/storage.php | sitemap storage type — local (root = FCPATH, visibility public) or s3 (with public CDN URL) |
Code Flow
1. Initialization
- Resolves the current language abbreviation for URL generation (constructor).
executeCommand()raises CLI limits for the full-catalogue streaming run:@set_time_limit(0)and@ini_set('memory_limit', '1G').- Lists every file under
storage('sitemap')->listContents('sitemap')and deletes each one. This wipes the previous run's chunks and index before the new set is written. There is a brief window during regeneration where crawlers may see a 404 for a chunk URL they cached earlier — accepted given the daily cadence; matches the behaviour of the pre-storage-refactor implementation. A first run on a fresh disk (or an adapter that throws on a missing directory) is caught and treated as "nothing to clean".
2. URL Collection
Each content type is collected independently and passed to addUrls(), which appends to an in-memory buffer and flushes a chunk file to disk (flushChunk()) as soon as the buffer reaches CHUNK_SIZE (25,000 URLs) — content types are no longer merged into one big array before anything is written. Each URL entry is a map with loc, changefreq, and priority keys.
Static Pages (always included)
| URL | Frequency | Priority |
|---|---|---|
Homepage (base_url()) | daily | 1.0 |
Vendors listing (/vendors) | weekly | 1.0 |
Categories (gated by XML_FEEDS.IS_ENABLED_SITEMAP_CATEGORIES)
Traverses the published category tree up to 4 levels deep via product_category_model->getRecordsTreeMemory(). All published categories are added with:
- Change frequency:
daily - Priority:
0.9
Published category IDs are collected for use in the vendor-category intersection later.
Products (gated by XML_FEEDS.IS_ENABLED_SITEMAP_PRODUCTS)
Products are streamed rather than loaded in one query:
product_model->getTopSaleProductIdsForSiteMap(500)runs once upfront and returns up to 500 non-deleted, non-zero-price product IDs ordered bytmp_shop_order_basketorder count (DESC) -- these keep priority 1.0 regardless of which batch they land in.product_model->getProductsForSiteMapBatch($afterId, 25000)is called in a loop, each call returning non-deleted, non-zero-price products withid > $afterIdordered byid ASC(keyset pagination), joined toproduct_codesforCOALESCE(SUM(stock), 0)instead of the old per-row correlated subquery. The loop advances$afterIdto the last row'sidand stops once a batch returns fewer than 25,000 rows.
Priority assignment (per row, checked against the upfront top-seller ID set):
| Condition | Priority |
|---|---|
| In the top-500 seller ID set | 1.0 |
| Inactive products | 0.1 |
| Negative stock with negative-stock disabled | 0.5 |
| All other products | 0.7 |
URL resolution: uses the product's url field if set; otherwise falls back to {vendors_base_url}/{vendor_slug}/{product_slug}.
The older product_model->getProductsForSiteMap() (single-query, order-count/active/stock sort) still exists in the codebase but is now entirely unused.
Vendors (gated by XML_FEEDS.IS_ENABLED_SITEMAP_VENDORS)
Three sub-collections:
Vendor pages via vendors_model->getVendorsForSiteMap():
- Top 15% of vendors: priority 1.0 (tracked as "top vendors")
- Remaining vendors: priority 0.8
- Exclusive vendors also get a
/detailspage at priority 0.5 - Change frequency:
weekly
Vendor product lines via lines_model->getLinesForSiteMap():
- Lines belonging to top vendors: priority 0.8
- Other lines: priority 0.6
- URL:
/vendors/{vendor_slug}/{line_slug}
Vendor-category pages via product_category_model->getVendorsCategoriesForSiteMap():
- Only for categories that were published (collected during category traversal)
- Top vendor categories: priority 0.8
- Other categories: priority 0.6
- URL:
/vendors/{vendor_slug}/{category_slug}
Blog Articles (gated by XML_FEEDS.IS_ENABLED_SITEMAP_BLOG)
Published articles with blog_date <= today via blog_model->getBlogsList():
- Change frequency:
weekly - Priority:
0.7 - URL:
/blog/{slug}
CMS Pages (gated by XML_FEEDS.IS_ENABLED_SITEMAP_STATIC_PAGES)
All CMS pages via categories_model->get_list():
- Change frequency:
yearly - Priority:
0.3 - URL:
/category/{slug}
3. Chunk and Index Writing
addUrls()appends every collected URL to an in-memory buffer; whenever the buffer reachesCHUNK_SIZE(25,000 URLs) it immediately callsflushChunk()on that slice, so a chunk can be written to disk mid-collection rather than only at the end.flushChunk()passes the chunk tositeMap()(ecommercen/helpers/xml_helper.php), which generates a compliant<urlset>XML document usingDOMDocument.- The chunk is written to
sitemap/sitemap-{N}.xmlviastorage('sitemap')->put(...). The job also capturesbase_url($chunkPath)into a$chunkUrlsarray — so the index always references the site's canonical host (/sitemap/sitemap-N.xml) regardless of the storage-disk backend, rather than the storage/CDN host thatstorage('sitemap')->url()would have returned on S3 tenants. - After all content types and product batches have been collected, any residual buffered URLs (fewer than
CHUNK_SIZE) are flushed as one final chunk. siteMapIndex($chunkUrls)builds the<sitemapindex>XML referencing every chunk written, and the job writes it tositemap/sitemap_index.xml. If no URLs were ever produced ($chunkUrlsempty), the index write is skipped entirely.
There is no controller in the request path — both chunks and index are static files served directly by nginx (local-disk tenants) or by the CDN edge (K8s tenants).
XML Structure
Sitemap chunk (sitemap-0.xml):
xml
<?xml version="1.0" encoding="utf-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/</loc>
<changefreq>daily</changefreq>
<priority>1</priority>
</url>
...
</urlset>Sitemap index (sitemap_index.xml):
xml
<?xml version="1.0" encoding="utf-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://example.com/sitemap/sitemap-0.xml</loc>
</sitemap>
...
</sitemapindex>Data Model
No dedicated tables. The job reads from:
| Table | Content Type |
|---|---|
shop_product + shop_product_mui + shop_vendor_mui | Products |
shop_product_category + shop_product_category_mui | Categories |
shop_vendor + shop_vendor_mui | Vendors |
shop_product_lines + shop_product_lines_mui | Product lines |
shop_product_category_lp | Vendor-category intersections |
product_codes | Product stock (COALESCE(SUM(stock),0) aggregated per product; feeds the product URL priority 0.5/0.7) |
blog + blog_mui | Blog articles |
categories + categories_mui | CMS pages |
tmp_shop_order_basket | Product popularity (upfront top-500 seller ID lookup for priority 1.0) |
Configuration
Job Scheduling (application/config/jobs.php)
php
['command' => 'GenerateSitemaps', 'schedule' => '30 2 * * *', 'graceTime' => 1800, 'retryTimes' => 1]Runs daily at 02:30 in its own dedicated sitemap queue (moved off the shared, locked core queue). The larger graceTime (1800s vs. the 300s the job used as a core command) accommodates the streaming run against large catalogues; retryTimes is reduced to 1 since retrying a full regeneration repeatedly is expensive.
Registry Settings (feature toggles)
| Group | Key | Purpose |
|---|---|---|
XML_FEEDS | IS_ENABLED_SITEMAP_CATEGORIES | Include categories in sitemap |
XML_FEEDS | IS_ENABLED_SITEMAP_PRODUCTS | Include products in sitemap |
XML_FEEDS | IS_ENABLED_SITEMAP_VENDORS | Include vendors, lines, and vendor-categories |
XML_FEEDS | IS_ENABLED_SITEMAP_BLOG | Include blog articles |
XML_FEEDS | IS_ENABLED_SITEMAP_STATIC_PAGES | Include CMS pages |
Output Location
Files are written to the dedicated sitemap storage type under the sitemap/ prefix. The actual location depends on the driver configured in application/config/storage.php:
- Local driver (single-server deployments): chunks and the index land at
FCPATH/sitemap/sitemap-N.xmlandFCPATH/sitemap/sitemap_index.xml. Nginx serves them as static files at/sitemap/.... - S3 driver (Kubernetes deployments): same paths but on
s3://{SITEMAP_S3_BUCKET}/{FILES_S3_PREFIX}/sitemap/. The configuredSITEMAP_S3_URLCDN endpoint serves them. Infra is expected to mount the CDN athttps://{app-host}/sitemap/(typically via CloudFront path-based routing) so the registered URL/sitemap/sitemap_index.xmlresolves to the S3 object without changing the public URL.
Driver selection is per-tenant via the SITEMAP_STORAGE_DISK env var (local or s3).
Client Extension Points
Override the job class: Extend
AdvGenerateSitemapsinapplication/modules/job/libraries/GenerateSitemaps.phpto:- Add custom content types (e.g., event pages, landing pages)
- Exclude specific categories or vendors
- Change priority assignment logic
- Modify URL patterns
Override
executeCommand(): There is no longer a singlegetUrls()aggregator to override — URL collection (theaddUrls()calls for static pages, categories, vendors, blog, pages) and the product streaming loop both live directly inexecuteCommand(). Override the whole method to add, remove, or reorder content types, keeping theaddUrls()/flushChunk()buffering intact so memory stays bounded.Override individual getters: Override
getCategories(),getVendors(),getBlog(), orgetPages()to customize filtering, ordering, or priority logic for those content types. Product URLs are no longer built by a singlegetProducts()getter — they come from the inline batch loop inexecuteCommand()callingproduct_model->getProductsForSiteMapBatch(); override that model method (or the loop itself) to customize product filtering/ordering.Override XML helpers: Replace
siteMap()andsiteMapIndex()inapplication/helpers/xml_helper.phpto change XML formatting (e.g., add<lastmod>timestamps or<image:image>extensions).Custom content types: Add new getter methods in a subclass and feed their output through
addUrls()inside an overriddenexecuteCommand().
Business Rules
| Rule | Description |
|---|---|
| Full regeneration | The job lists everything under sitemap/ and deletes it before writing the new chunk set + index; no incremental updates |
| Static index | The sitemap index is written by the cron as a static file (sitemap/sitemap_index.xml) — no PHP request path |
| 25,000 URLs per file | Sitemaps are chunked at CHUNK_SIZE = 25,000 URLs per file, flushed to disk as soon as the in-memory buffer fills (streamed, not a single array_chunk at the end) |
| Feature-gated content | Each content type can be independently enabled/disabled via registry |
| Priority-based ranking | The top 500 sellers (from a separate upfront tmp_shop_order_basket query) get maximum priority; the main product stream itself is ordered by id ASC for keyset pagination, not by popularity |
| Exclusive vendor pages | Vendors with is_exclusive = 1 get an additional /details page |
| Top vendor boost | Top 15% of vendors (by position) receive priority 1.0 |
| Published content only | Categories must be published = 1; blog articles must have blog_date <= today |
| Non-zero price | Products with zero price are excluded |
| Non-deleted products | Soft-deleted products are excluded |
| Language-aware URLs | Product and category URLs use the configured language abbreviation |
Known Issues & Security Gaps
None currently known. (Previously: the exclusive-vendor /details page was never emitted because AdvGenerateSitemaps.php:253 compared is_exclusive — returned as the string "1" from the raw CI3 query in getVendorsForSiteMap(), Adv_vendors_model.php:507 — with strict === 1. Fixed in 4.119.0 by casting: (int) $vendor->is_exclusive === 1 — Advisable-com/ecommercen#459.)
Related Flows
- SY-01 Cron Job Framework -- job scheduling and execution
- AD-14 SEO Management -- broader SEO system including robots.txt and meta tags