nvl / seo
Localized SEO metadata, canonical URLs, social previews, structured data, robots, and sitemaps for Laravel
Requires
- php: ^8.3
- ext-filter: *
- ext-json: *
- ext-libxml: *
- ext-mbstring: *
- ext-xmlwriter: *
- laravel/framework: ^13.0
- nesbot/carbon: ^2.72 || ^3.0
- nvl/core: ^2.0
- nvl/tenancy: ^2.0
- nvl/translatable: ^2.0
- spatie/laravel-data: ^4.23
- spatie/typescript-transformer: ^3.3
- symfony/http-kernel: ^7.2 || ^8.0
Requires (Dev)
- ext-pdo: *
- larastan/larastan: ^3.10
- laravel/pint: ^1.27
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-26 07:35:39 UTC
README
For support, open an issue. For vulnerabilities, use private reporting. See Contributing.
See the installation and publishing guide for Composer setup, configuration, migration ownership, and agent skills.
Quick reference
| Item | Value |
|---|---|
| Installed through | composer require nvl/seo:^2.0 |
| Module identifier | nvl/seo |
| PHP namespace | Nvl\Seo |
| Service provider | Nvl\Seo\Providers\SeoServiceProvider |
| Configuration | config/seo.php |
A standalone localized SEO system for Laravel 13: polymorphic profiles, deterministic translations, canonical and hreflang URLs, social cards, safe JSON-LD, media integration, robots policies, and bounded cached XML sitemaps.
Purpose and boundaries
SEO owns discoverability metadata from persistence through rendering and crawl discovery:
- one site-scoped profile for any Eloquent model;
- locale rows managed by
nvl/translatable; - database-enforced normalized path uniqueness;
- resolved title, description, canonical, robots, social, and structured data;
- escaped server-rendered head markup;
- an image resolver for direct URLs or media libraries;
- sitemap source registration, XML generation, caching, and opt-in routes;
- robots.txt generation and an opt-in route;
- Spatie Data and generated TypeScript contracts.
It does not own an admin UI, application roles/policies, content routing, analytics, Search Console submission, CDN configuration, or a hard dependency on nvl/media. It provides an optional headless redirect subsystem.
Optional tenant/site identity
When nvl/tenancy is enabled and seo is adopted, profiles, translations,
redirects, redirect graph locks, sitemap manifests, and invalidation are
tenant-local. Every operation requires the active tenant plus a verified
TenantSiteContext; scope must equal the verified site and absolute URLs use
its canonical origin. No configuration value is mutated to emulate request
state.
Register owners through SeoOwnerRegistry and tenant resources through
TenantResourceRegistry. Sitemap sources use class registrations, implement
TenantSafeSitemapSource, declare every queried resource, and are constructed
fresh inside the active scope. Cached artifacts are keyed by effective
connection, tenant, site, origin, scope, and version. Mutations capture that
identity before commit so deferred invalidation cannot delete another tenant's
new namespace. A Media-backed adapter implements TenantSafeSeoImageResolver
and validates its reference before reading or projecting the asset; the direct
resolver rejects model-backed references.
Adoption rehashes redirects with reviewed tenant identity and derives profile and translation ownership from canonical registered owners. Run bounded prepare/backfill/verify/activate under maintenance, remove obsolete global artifacts, drain jobs, and restart workers before admitting traffic.
Requirements and installation
- PHP 8.4+
- Laravel 13
nvl/translatable2.xnvl/core2.xext-json,ext-libxml,ext-xmlwriter, andext-mbstring
composer require nvl/seo:^2.0 php artisan migrate php artisan vendor:publish --tag=seo-config php artisan vendor:publish --tag=seo-skills
Publish migration sources only when the application requires copied migrations:
php artisan vendor:publish --tag=seo-migrations
Choose exactly one migration owner. For automatic vendor loading, leave
seo.migrations.enabled=true and do not publish seo-migrations. For
host-owned migrations, publish seo-migrations, set
seo.migrations.enabled=false before the first migration, and maintain the
copied files as application migrations. Never run both sources; Laravel
retimestamps published migrations.
Configure content locales:
// config/translatable.php return [ 'locales' => ['en', 'bg'], 'fallback_locales' => ['en'], ];
Data model
seo_profiles stores stable attachment and nonlocalized crawl policy:
- scope, polymorphic type, and owner identifier;
- index/follow and preview directives;
- sitemap inclusion, priority, and change frequency;
- application metadata.
seo_profiles_i18n stores:
- locale and normalized route path;
- title and description;
- canonical override;
- direct image URL or application media reference and alt text;
- Open Graph and Twitter overrides;
- JSON-LD structured data;
- locale-specific metadata.
The database enforces one profile per scope/owner, one translation per profile/locale, and one normalized path per scope/locale. String owner identifiers support integer, UUID, ULID, and custom keys.
Add SEO to a model
The action accepts any persisted Eloquent model. The convenience trait is optional:
use Nvl\Seo\Traits\HasSeo; final class Article extends Model { use HasSeo; }
$article->seoProfiles(); // all scopes $article->seoProfile(); // configured default $article->seoProfile('publication'); // explicit scope
The trait only reads relations; writes always go through Actions.
Polymorphic owner rows cannot have a database-level cascading foreign key. The
trait deliberately does not install a deleting observer, because owner deletion
policy belongs to the application. Before hard-deleting an owner, explicitly
delete each profile through DeleteSeoProfileAction; for soft-deleted owners,
choose whether profiles should remain resolvable or be archived. This keeps
revision checks, domain events, translation cleanup, and sitemap invalidation
inside the supported mutation boundary.
Create or update
use Nvl\Seo\Actions\SyncSeoProfileAction; use Nvl\Seo\Data\Mutations\SeoProfilePayload; $payload = SeoProfilePayload::validateAndCreate([ 'isIndexable' => true, 'isFollowable' => true, 'sitemapIncluded' => true, 'sitemapPriority' => '0.8', 'sitemapChangeFrequency' => 'weekly', 'translations' => [ 'en' => [ 'path' => '/articles/reliable-systems', 'title' => 'Reliable Systems', 'description' => 'A practical guide to reliable systems.', 'imageUrl' => 'https://cdn.example.com/reliable-systems.jpg', 'imageAlt' => 'A diagram of a reliable system', 'structuredData' => [ '@context' => 'https://schema.org', '@type' => 'Article', 'headline' => 'Reliable Systems', ], ], 'bg' => [ 'path' => '/bg/statii/nadezhdni-sistemi', 'title' => 'Надеждни системи', 'description' => 'Практическо ръководство за надеждни системи.', ], ], ]); $profile = app(SyncSeoProfileAction::class)->execute( owner: $article, data: $payload, );
The action owns the transaction, writes translations through TranslationWriter, refreshes state, dispatches SeoProfileChanged after commit, and invalidates sitemap cache after commit. The owner must be persisted.
For a race-safe first write, pass expectedRevision: 0. Updates use the exact
positive revision returned by SeoProfileData. Programmatic callers may omit
the token only when deliberately choosing last-write-wins behavior; management
updates always require it.
Patch and replace
Patch is the safe default:
$sync->execute($article, SeoProfilePayload::from([ 'translations' => [ 'en' => ['title' => 'Updated title'], ], ]));
It changes only supplied fields/locales. Explicit null clears a field; omitted data remains.
Replace deletes omitted locales:
$sync->execute( owner: $article, data: $payload, translationMode: TranslationSyncMode::Replace, );
Require deliberate confirmation before replace.
Site scopes
$profile = $sync->execute( owner: $article, data: $payload, scope: 'wholesale-store', );
Scope is immutable profile identity. It is normalized to a lowercase 1–100 character slug using letters, numbers, dots, underscores, and hyphens. Paths may repeat across scopes but not within one scope and locale.
Localized reads and centralized discovery
SeoProfile implements TranslatableModel:
$title = $profile->translated('title', 'bg'); $resolution = $profile->resolveTranslation('description', 'bg'); $resolution->resolvedLocale; $resolution->usedFallback();
Resolution is per field. Null may fall back; an intentional empty string remains empty. Use withAllTranslations() for administrative reads.
SEO self-registers as seo.profiles in TranslationResourceRegistry, so it
participates in nvl:translatable:gather and centralized reporting without a
second translation system. Its mutation policy is DomainActionOnly:
centralized translation writes are rejected because they would bypass profile
revision, route-conflict, event, and sitemap-invalidation invariants. Write
localized SEO through SyncSeoProfileAction.
Resolve and render metadata
$seo = $resolver->resolve( owner: $article, locale: 'bg', scope: 'default', ); $seo->title; $seo->description; $seo->canonicalUrl; $seo->robots; $seo->alternates; $seo->openGraph; $seo->openGraphLocales; $seo->twitter; $seo->structuredData;
Resolution applies requested locale/fallback, profile values and localized overrides, site defaults, title branding, and absolute URL resolution from seo.site.base_url.
If no canonical override exists, the normalized path becomes the self-referential
canonical. Alternates include every routable translation and x-default when
the fallback locale is routable. openGraphLocales carries the other available
locales separately so the renderer can emit repeated og:locale:alternate
properties without weakening the typed Open Graph map.
Render in Blade:
{!! $seoManager->for($article, 'bg')->toHtml() !!}
Or:
@seo($article)
SeoHeadRenderer escapes titles and attributes and encodes JSON-LD with HTML-sensitive flags. It never accepts pre-rendered tags. For Inertia/API frontends, serialize ResolvedSeoData.
Social images and nvl/media
Direct URLs use DirectSeoImageResolver. To resolve imageReference with Media, implement the host adapter:
final readonly class MediaSeoImageResolver implements SeoImageResolver { public function __construct( private MediaAssetService $assets, ) { } public function resolve(SeoImageContext $context): ?SeoImage { if ($context->reference === null) { return null; } $media = Media::query()->find($context->reference); if ($media === null || ! $media->is_public) { return null; } return new SeoImage( url: $this->assets->url($media), alt: $context->alt, ); } }
$this->app->bind(SeoImageResolver::class, MediaSeoImageResolver::class);
SeoImageContext provides the owner profile, selected locale row, requested locale, and field-level fallback values. Only public, crawler-accessible assets should become social URLs. Use MediaAssetService; never concatenate storage paths or expose private signed URLs as permanent previews.
Resource-aware structured data
$schema = $builder->schema('Article', [ 'headline' => 'A localized article', 'datePublished' => '2026-07-27T09:00:00+00:00', ]); $breadcrumbs = $builder->breadcrumbs([ ['name' => 'Home', 'url' => 'https://example.com'], ['name' => 'Articles', 'url' => 'https://example.com/articles'], ]);
A translation may store one associative schema object, a list of objects, or an
@graph. Editor-authored data is useful for exceptional pages, but repeatable
resource schemas should use a provider so the output stays synchronized with
the underlying model.
Implement StructuredDataProvider for any Eloquent resource:
use Illuminate\Database\Eloquent\Model; use Nvl\Seo\Contracts\StructuredDataProvider; use Nvl\Seo\Data\StructuredDataContextData; use Nvl\Seo\Data\StructuredDataNodeData; use Nvl\Seo\Enums\StructuredDataType; final class ArticleStructuredDataProvider implements StructuredDataProvider { public function provide( Model $resource, StructuredDataContextData $context, ): iterable { yield StructuredDataNodeData::make( type: StructuredDataType::Article, id: $context->canonicalUrl.'#article', properties: [ 'headline' => $resource->title, 'description' => $context->description, 'url' => $context->canonicalUrl, 'datePublished' => $resource->published_at?->toAtomString(), 'dateModified' => $resource->updated_at?->toAtomString(), 'mainEntityOfPage' => [ '@id' => $context->canonicalUrl.'#webpage', ], ], ); } }
Register it in configuration:
'structured_data' => [ 'providers' => [ [ 'key' => 'content.article', 'resource' => Article::class, 'provider' => ArticleStructuredDataProvider::class, 'priority' => 100, ], ], ],
Or register a resolved provider from an application service provider:
$registry->register( key: 'content.article', resourceClass: Article::class, provider: $provider, priority: 100, );
StructuredDataContextData supplies the resource/profile identity, locale,
scope, canonical URL, title, description, image, site name, and site URL. A
provider therefore does not need to repeat SEO resolution or infer the active
locale.
Graph composition and precedence
By default SEO emits one script containing a shared-context @graph:
- a stable
WebSitenode ({siteUrl}#website); - a stable
WebPagenode ({canonicalUrl}#webpage); - provider nodes in ascending priority and deterministic key order;
- persisted translation nodes last.
Nodes with the same @id are merged. Higher-priority providers override lower
priorities, and explicit persisted fields override generated fields. Anonymous
nodes are preserved. Use stable absolute IDs and references so Article,
Product, Event, Organization, Person, BreadcrumbList, ImageObject, and other
nodes form one connected graph.
Configure the source policy explicitly:
'structured_data' => [ 'mode' => 'merge', // persisted | generated | merge 'automatic_web_site' => true, 'automatic_web_page' => true, 'maximum_bytes' => 262_144, 'maximum_depth' => 16, 'maximum_items' => 1_000, ],
StructuredDataType provides convenient common types but never acts as a
closed allowlist; future or extension schema.org types may be supplied as safe
type strings, including valid leading-digit names such as 3DModel.
StructuredDataBuilder::node(), reference(), graph(),
schema(), and breadcrumbs() protect JSON-LD identity and shape.
Validation enforces JSON encodability, schema.org context, root type/graph grammar, safe node identifiers, and configured size/depth/item limits. It does not claim semantic completeness or rich-result eligibility. Providers must include only accurate information represented in the visible page and must validate type-specific required fields before returning a node.
Verify rendered pages with the Google Rich Results Test and the Schema.org Markup Validator. Search engines may understand valid schema.org data without offering a rich result, and valid markup never guarantees one.
Incoming paths
$profile = app(SeoRouteResolver::class)->resolve( path: '/bg/produkti/chervena-roklya/', locale: 'bg', scope: 'default', );
The resolver uses the same normalization/fingerprint as writes and loads translations. Application routing remains application-owned.
UniqueSeoPath provides an early validation error; the database constraint remains the race-safe authority.
Owner-centric profile reads
Use owner Actions when application code already has the registered Eloquent
owner. This avoids navigating seoProfiles, selecting a raw SeoProfile, or
calling SeoProfileData::fromModel() with a duplicated alias:
use Nvl\Seo\Actions\GetOwnerSeoProfileAction; use Nvl\Seo\Actions\GetOwnerSeoRevisionAction; use Nvl\Seo\Actions\ListOwnerSeoProfilesAction; $profile = app(GetOwnerSeoProfileAction::class)->execute($page, $page->site); $revision = app(GetOwnerSeoRevisionAction::class)->execute($page, $page->site); $profiles = app(ListOwnerSeoProfilesAction::class)->execute($pages, $page->site);
GetOwnerSeoProfileAction returns an authorized SeoProfileData with its
translations eager-loaded, or null when the normalized scope has no profile.
GetOwnerSeoRevisionAction performs the lightweight concurrency read and
returns SeoOwnerRevisionData: registered owner alias, string owner ID,
normalized scope, nullable profile ID, and revision. An absent profile has
revision 0, which is the create token accepted by profile mutations.
ListOwnerSeoProfilesAction accepts at most 100 registered, persisted owners
and returns one SeoProfileData or null for each owner in the same positional
order. It authorizes every owner before profile SQL, then batches profiles and
translations in at most two queries whether one or 25 owners are supplied. Use
it when a bounded editor index already has its owner models; do not eager-load
seoProfiles and construct privileged DTOs outside SEO.
All three Actions resolve aliases through SeoOwnerRegistry and authorize
SeoAbility::View before querying the profile table. Bind SeoAuthorization
or configure seo.authorization.ability; the default remains fail-closed.
Optional management API
The package is headless and management routes are disabled by default. Register stable aliases rather than accepting model classes from HTTP:
'owners' => [ 'article' => Article::class, 'page' => Page::class, ], 'management' => [ 'enabled' => true, 'path' => 'api/v1/seo', 'name' => 'nvl.seo.management.', 'middleware' => ['api', 'auth', 'throttle:60,1'], ], 'authorization' => [ 'ability' => 'manage-seo', ],
The path and route-name prefix are validated; the name receives a trailing dot
automatically. Bind SeoAuthorization for policy-based control or configure a
Gate ability. The default implementation fails closed. Each authorization
decision receives SeoAuthorizationContext with the operation, profile,
registered source owner/alias, target owner/alias for duplicates, and scope.
| Method | Relative path | Default name | Purpose |
|---|---|---|---|
GET |
/profiles/status |
nvl.seo.management.profiles.status |
Aggregate active/archived counts |
GET |
/profiles |
nvl.seo.management.profiles.index |
Filtered paginated list |
POST |
/profiles |
nvl.seo.management.profiles.store |
Create for a registered owner alias |
GET |
/profiles/{profile} |
nvl.seo.management.profiles.show |
Inspect privileged profile data |
PUT |
/profiles/{profile} |
nvl.seo.management.profiles.update |
Optimistic update |
POST |
/profiles/{profile}/duplicate |
nvl.seo.management.profiles.duplicate |
Duplicate to an authorized target |
PATCH |
/profiles/{profile}/archive |
nvl.seo.management.profiles.archive |
Archive or restore |
GET |
/profiles/{profile}/preview |
nvl.seo.management.profiles.preview |
Resolve a locale preview |
DELETE |
/profiles/{profile} |
nvl.seo.management.profiles.destroy |
Optimistic delete |
List filters are scope, status, ownerAlias, page, and perPage
(maximum 200). Responses use data.items and data.meta. Create accepts
ownerAlias, ownerId, optional scope, and a nested profile payload.
Update, archive, and delete require the current revision. Duplicate clears
localized paths and canonicals by default; copyPaths=true is explicit and
still subject to path-conflict enforcement.
Package domain failures use a stable JSON envelope for API requests:
{
"error": {
"code": "stale_seo_profile",
"message": "…",
"context": {
"profileId": "…"
}
}
}
Conflict codes use HTTP 409; invalid domain mutations use HTTP 422.
Sitemaps
Enable public routes only if the application does not own them:
'routes' => [ 'enabled' => true, 'middleware' => ['web'], 'name' => 'nvl.seo.', 'sitemap_path' => 'sitemap.xml', 'sitemap_chunk_path' => 'sitemap-{chunk}.xml', 'sitemap_scopes' => ['wholesale'], 'robots_path' => 'robots.txt', ],
EloquentSeoSitemapSource reads included/indexable translations in database
chunks with optional frequency/priority and localized alternates:
$xml = app(SitemapGenerator::class)->generate('default');
Add application URLs with SitemapSource:
final class StaticPagesSitemapSource implements SitemapSource { public function entries(string $scope): iterable { yield new SitemapEntry('https://example.com/about'); yield new SitemapEntry('https://example.com/contact'); } }
Register the class in seo.sitemap.sources.
An owner package can take exclusive responsibility for its profiles by calling
SitemapRegistry::register($source, $key, ownerTypes: [$owner::class]).
Register stable model classes; their current morph aliases are resolved when
the sitemap is read, including aliases registered later during host boot.
The built-in profile source excludes those owner types, allowing the owner
source to enforce publication, soft deletion, tenancy, and explicit SEO
exclusions in bounded queries. The owner source may reuse
EloquentSeoSitemapSource::entriesForProfile($profile) with eager-loaded
translations for canonical and hreflang parity. Each owner model has one source;
profiles without a delegated owner type keep the standalone SEO behavior.
Every source is registered under a unique deterministic key. A cached build
scans the sources once, writes completed XML to the configured Laravel
filesystem, and publishes only a small cache manifest after every artifact is
durable. Concurrent builders use the cache store's atomic lock. Each artifact
is bounded by both seo.sitemap.max_urls (maximum 50,000) and
seo.sitemap.max_bytes (maximum 52,428,800 uncompressed bytes). When multiple
chunks exist and indexes are enabled, the primary route emits a sitemap index.
Non-default index links retain their scope through a validated query parameter.
List every publicly routable non-default scope in routes.sitemap_scopes; the
configured site.scope is always allowed. This allowlist prevents public
requests from manufacturing unbounded cache namespaces. The generator fails
instead of publishing an incomplete or oversized set.
Configure artifact_store, disk, directory, cache_seconds, cache_key,
max_urls, max_bytes, index_enabled, lock_seconds, and
lock_wait_seconds. A positive cache TTL requires a cache store implementing
atomic locks; the doctor verifies it. Cache/artifact namespaces include the
configured site URL and scope, preventing collisions when infrastructure is
shared across sites. Same-origin and sitemap-directory scope checks are enabled
by default. Valid cross-origin canonical overrides are omitted as primary
entries by the built-in source because noncanonical local URLs do not belong in
the site sitemap; they may still appear as hreflang alternates for an eligible
local entry. Invalid custom-source locations still fail the build. A completed
manifest whose filesystem artifact disappeared is invalidated and rebuilt
once. The built-in source intentionally omits lastmod: an SEO-row timestamp
does not prove that page content changed. Custom sources may pass an accurate
source-owned lastModified.
Warm or invalidate one scope explicitly:
php artisan nvl:seo:sitemap:warm --scope=default php artisan nvl:seo:sitemap:clear --scope=default
Warming requires a positive sitemap cache TTL; zero-TTL generation is intentionally request-local and does not publish artifacts.
Sitemap, chunk, and robots responses include ETag, conditional 304 support,
nosniff, and matching public cache headers. A zero TTL uses
no-store, private.
Robots
'robots' => [ 'user_agent' => '*', 'allow' => ['/'], 'disallow' => ['/internal'], 'include_sitemap' => true, 'cache_seconds' => 3600, 'maximum_bytes' => 512000, ],
Directive paths reject whitespace/control characters and fragment markers to
prevent line/comment injection, and generated files are capped at 500 KiB. Use
isIndexable=false for noindex. robots.txt Disallow controls crawling, not
guaranteed indexing, and is not canonicalization.
Configuration
site.scope: default site/storefront identity;site.name,title_separator,title_position: title branding;site.base_url: canonical and sitemap origin;site.default_image_url: direct social fallback;site.open_graph_type,twitter_site,twitter_creator: social defaults;defaults: metadata and robots defaults;image_resolver: image contract implementation;routes: opt-in public paths, public sitemap-scope allowlist, route-name prefix, and middleware;management: opt-in API path, route-name prefix, and middleware;owners: stable management/import aliases for Eloquent resource classes;authorization.ability: fail-closed management Gate ability;structured_data.mode, automatic baseline nodes, providers, and safety limits;sitemap.sources, artifact storage, cache/lock controls, origin/scope enforcement, and URL/byte limits;redirects: resolver availability, chain bound, and hit recording;robots.user_agent,allow,disallow,include_sitemap, cache TTL, and byte limit.
Keep environment access in config. Application code reads config('seo...').
TypeScript
SEO registers with Core's Data provider. Public contracts include SeoProfilePayload, SeoTranslationPayload, ResolvedSeoData, SitemapChangeFrequency, and TwitterCard.
php artisan nvl:data:types:generate php artisan nvl:data:types:check
Redirects and imports
SyncSeoRedirectAction, SeoRedirectResolver, and SeoRedirectChain provide
site/locale-aware redirects with status validation, normalized identity,
expiry, loop detection, bounded chain resolution, optimistic revisions, and
hit metadata. Revision 0 protects first creates. Internal targets preserve
safe query strings/fragments; network-path targets and non-HTTP schemes are
rejected. Localized resolution prefers an exact locale and then falls back to a
locale-neutral redirect. Redirect mutations serialize through a stable database
mutex until the outer transaction commits, and validate the resulting graph in
every configured locale, including fallback exposed by deactivation or source
changes. Run the create_seo_redirect_locks_table migration before using these
mutations. Redirect delivery remains application-owned: resolve
the request path and return the typed ResolvedRedirectData decision where
appropriate.
Soft-deleted redirects can be pruned after a retention window:
php artisan nvl:seo:redirects:prune --days=30
Application adoption uses the neutral cursor-paginated SeoImportSource
contract and ImportSeoProfilesAction. Import records use ownerAlias, never
an HTTP-supplied model class. Model and path mapping belong to an
application-owned bridge. Normalize duplicate paths, preserve compatible
identifiers, and verify canonical, hreflang, redirect, and sitemap output
before cutover.
Run the read-only doctor first:
php artisan nvl:seo:doctor --strict --format=json
Production checklist
- Run migrations and validate mutation DTOs.
- Keep management APIs disabled unless middleware and
SeoAuthorizationare configured. - Use absolute HTTPS canonical/social URLs.
- Expose only public images.
- Never store raw HTML or JSON-LD script strings.
- Resolve duplicate paths before deployment.
- Verify reciprocal hreflang and
x-default. - Keep sitemap generation bounded and cached.
- Store generated sitemap XML on a durable private filesystem disk.
- Use an atomic-lock-capable cache store when sitemap caching is enabled.
- Configure production robots policy.
- Test rendered pages with search-engine validators.
Quality
composer install composer quality
The package gate runs Pint, PHPStan at maximum strictness, and isolated Testbench/Pest tests. Maintainer CI adds dependency analysis, Composer audit, integration tests, and Laravel 13 gates.
See UPGRADING.md, SECURITY.md, CONTRIBUTING.md, and CHANGELOG.md.
License
Released under the MIT License.