nvl / seo
Localized SEO metadata, canonical URLs, social previews, structured data, robots, and sitemaps for Laravel
Requires
- php: ^8.4
- ext-filter: *
- ext-json: *
- ext-libxml: *
- ext-mbstring: *
- ext-xmlwriter: *
- laravel/framework: ^12.0 || ^13.0
- nesbot/carbon: ^2.72 || ^3.0
- nvl/core: ^6.0.1
- nvl/translatable: ^6.0
- spatie/laravel-data: ^4.23
- symfony/http-foundation: ^7.0 || ^8.0
- symfony/http-kernel: ^7.2 || ^8.0
Requires (Dev)
- ext-pdo: *
- larastan/larastan: ^3.10
- laravel/pint: ^1.27
- mockery/mockery: ^1.6
- nvl/content: ^6.0
- nvl/media: ^6.0
- nvl/ownership: ^6.0.1
- nvl/tenancy: ^6.0
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- spatie/typescript-transformer: ^3.3
Suggests
- nvl/ownership: Adds optional model-declared ownership, isolation, adoption and captured worker contexts.
- nvl/tenancy: Temporary legacy tenancy bridge for existing tenant installations; native ownership does not require it.
- spatie/typescript-transformer: Consume the declared TypeScript attributes and metadata (^3.3); Core retains the runtime transformation engine.
Provides
None
Conflicts
- nvl/ownership: <6.0.1 || >=7.0
- nvl/tenancy: <6.0 || >=7.0
Replaces
None
README
Optional native ownership and public sites
SEO remains unscoped without ownership; it requires neither NVL Auth nor Tenancy.
Set nvl-seo.ownership.owner_resources to the exact registered native host keys
(for Pages, include pages.pages). Profiles and locale rows inherit their canonical
host; redirects capture their complete native primary owner. Every resource and
retained row must pass reviewed adoption before activation. Profile paths and
redirect identities become owner-local. The adopter preserves IDs and source/target
paths, rebinds the derived legacy tenant redirect hash and restores native child
FKs while retaining legacy composites. A separate optional owner-redirect lock
serializes mutations without reusing released tenant lock rows.
Bind Nvl\Ownership\Contracts\OwnerSiteResolver to your persisted public origin
registry and return VerifiedOwnerSiteContext with the full owner, site, canonical
origin and explicit read resources. Owned public routes admit this context before
bindings or cache work. Canonical, alternate, sitemap and robots URLs use the
verified origin; a caller cannot choose an owner or foreign site through query
parameters. Public admission grants no management or sharing rights.
Sitemap artifact identities include the native owner/generation, actual source,
site, origin, scope and version. Management edits invalidate that owner/site's
public version even outside HTTP. Register owned sources by class with
OwnershipSafeSitemapSource::ownershipResources(); the source graph is admitted
before enumeration. Model-backed social images require
OwnershipSafeSeoImageResolver::assertOwnershipSafe() and a canonical same-owner
Media lookup before resolution. The direct URL resolver rejects opaque Media
references.
For synchronous scheduled builds, run
php artisan nvl:seo:sitemap:warm --origin=https://example.test.
The command resolves and revalidates the same host mapping. Owned warming without
an origin fails closed. Public admission cannot be captured into an actor/system
job; any asynchronous export must use a separately authorized system workflow.
Quickstart
composer require nvl/seo:^6.0 php artisan nvl:install seo --dry-run php artisan nvl:install seo
Required NVL dependencies: nvl/core (^6.0), nvl/translatable (^6.0). Register authorized owners and scope/locale policy before managing profiles. Resolve route/path inputs on the server.
Review the published common config, select one migration owner, and run schema preflight before existing-table upgrades. The installer does not enable features or run migrations. Follow the detailed installation and capability sections below before invoking a storage/provider operation.
Inject Nvl\Seo\Contracts\SeoProfileStatusContract in a host service. After supplying the trusted inputs described above, the first public call is:
use Nvl\Seo\Contracts\SeoProfileStatusContract; /** @var SeoProfileStatusContract $capability */ $result = $capability->execute();
Use the event catalog and Testing your app below. The suite getting-started guide provides a complete Comments host fixture; package archives retain their own local references.
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.
Native owned SEO hosts require explicit ownership.owner_resources; an unscoped SEO graph cannot contain native owned hosts, including registrations added later. Warm sitemap reads revalidate source capabilities. Redirect pruning uses the configured SEO source and the admitted owner; run scheduled maintenance through an authorized owner workflow. An unadmitted CLI command or public site context cannot prune redirects.
Quick reference
| Item | Value |
|---|---|
| Installed through | composer require nvl/seo:^6.0 |
| Module identifier | nvl/seo |
| PHP namespace | Nvl\Seo |
| Service provider | Nvl\Seo\Providers\SeoServiceProvider |
| Configuration | config/nvl-seo.php |
A standalone localized SEO system for Laravel 12–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 12–13
nvl/translatable2.xnvl/core2.xext-json,ext-libxml,ext-xmlwriter, andext-mbstring
composer require nvl/seo:^6.0 php artisan migrate php artisan vendor:publish --tag=nvl-seo-translations php artisan vendor:publish --tag=nvl-seo-config php artisan vendor:publish --tag=nvl-seo-skills
Publish migration sources only when the application requires copied migrations:
php artisan vendor:publish --tag=nvl-seo-migrations
Choose exactly one migration owner. For automatic vendor loading, leave
nvl-seo.migrations.enabled=true and do not publish nvl-seo-migrations. For
host-owned migrations, publish nvl-seo-migrations, set
nvl-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/nvl-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; }
use Nvl\Seo\Contracts\GetOwnerSeoProfileContract; $profile = app(GetOwnerSeoProfileContract::class)->execute($article, scope: 'publication');
GetOwnerSeoProfileAction supplies the authorized SeoProfileData projection.
Use ListOwnerSeoProfilesAction for owner lists. The trait's seoProfiles() and
seoProfile() methods are internal storage/query helpers; consumers obtain
projections through the Actions and never traverse these relations.
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\Contracts\SyncSeoProfileContract; 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(SyncSeoProfileContract::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 nvl-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:
@nvlSeo($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\Contracts\GetOwnerSeoProfileContract; use Nvl\Seo\Contracts\GetOwnerSeoRevisionContract; use Nvl\Seo\Contracts\ListOwnerSeoProfilesContract; $profile = app(GetOwnerSeoProfileContract::class)->execute($page, $page->site); $revision = app(GetOwnerSeoRevisionContract::class)->execute($page, $page->site); $profiles = app(ListOwnerSeoProfilesContract::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 nvl-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' => 'nvl/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' => 'nvl/seo/sitemap.xml', 'sitemap_chunk_path' => 'nvl/seo/sitemap-{chunk}.xml', 'sitemap_scopes' => ['wholesale'], 'robots_path' => 'nvl/seo/robots.txt', ],
These defaults reserve NVL-owned URLs. Sitemap directory scope remains enforced: a sitemap under /nvl/seo includes only same-origin URLs below that directory. For a site-wide sitemap, explicitly set nvl-seo.routes.sitemap_path to sitemap.xml and sitemap_chunk_path to sitemap-{chunk}.xml after reviewing the host's root routes. Keep nvl-seo.sitemap.enforce_path_scope=true. Set robots_path to robots.txt explicitly when the host delegates its standard crawler endpoint to NVL.
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 nvl-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 nvl-seo.sitemap.max_urls (maximum 50,000) and
nvl-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('nvl-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. Local Dagger verification adds dependency analysis, Composer audit, integration tests and PHP 8.4/Laravel 13 release checks; Laravel 12 and PHP 8.5 require separate compatibility evidence.
See UPGRADING.md, SECURITY.md, CONTRIBUTING.md, and CHANGELOG.md.
Supported PHP usage
The source @api declarations identify supported workflows, extension contracts, and value types. Public members marked @internal and untagged implementation types remain package-owned. Concrete Actions retain their existing constructors, qualifiers, and execute() signatures.
A package model returned or accepted by a public workflow is an identity/result handle. Use its declared type and getKey(), getKeyName(), getMorphClass(), getRouteKey(), getRouteKeyName(), is(), isNot(), and relationLoaded(). Read only explicitly declared in-memory @nvl-consumer-read fields; ordinary model PHPDocs and fillable attributes do not grant consumer reads. Obtain display projections through public reads. Persistence, additional model queries, relation access/loading, and generic model serialization are outside this contract. Host-model queries remain available, while traversal or aggregates of package capability relations require the package public reader or authorized adapter.
Testing your app
Inject the focused SEO workflow contracts into application orchestration;
existing sitemap, image, source, and authorization contracts remain the
extension boundaries. Concrete Actions retain their native constructors and
execute methods. PruneSeoRedirectsAction remains an operational internal.
use Nvl\Seo\Contracts\DeleteSeoProfileContract; use Nvl\Seo\Models\SeoProfile; final readonly class DeleteProfile { public function __construct(private DeleteSeoProfileContract $profiles) {} public function run(SeoProfile $profile): bool { return $this->profiles->execute($profile); } } $profile = SeoProfile::factory()->withoutParents()->make(); $profiles = Mockery::mock(DeleteSeoProfileContract::class); $profiles->shouldReceive('execute')->once()->with($profile)->andReturn(true); $this->app->instance(DeleteSeoProfileContract::class, $profiles); expect($this->app->make(DeleteProfile::class)->run($profile))->toBeTrue();
make() is an unsaved model handle for orchestration. Use persisted host owners
and the real workflows for ownership, revisions, translations, and redirects;
factory persistence requires the configured schema. Use Storage::fake() for
real sitemap artifact behavior and Http::fake() for host remote image/source
integrations. A mocked delete result does not prove authorization or deletion.
In Laravel application tests, register a native Mockery interface mock or a small
implementation with $this->app->instance(Contract::class, $substitute) before
resolving your application service. A host binding installed before package
registration is retained; later contract replacements affect subsequent
resolutions. Rebuild previously resolved host services after replacing their
dependencies. Concrete implementations remain callable with their original
constructors through major 5. Mocks exercise your application orchestration;
package authorization, persistence, and external effects need real integration
tests.
Image and sitemap artifact defaults now use bindIf. Public source/authorization APIs and native transient extension lifetimes are retained; redirect pruning remains internal.
For static consumer checks, include the shipped
consumer-audit.neon from
vendor/nvl/core/support/consumer-audit.neon in your host PHPStan configuration
and configure explicit nvlConsumer.testPaths for factory-backed tests. The
extension checks supported APIs and model/query boundaries; it does not prove
authorization or arbitrary dynamic SQL.
Shared owner identity
Declare a model once in config/nvl-core.php:
'owners' => [Article::class],
Enable this package capability separately in config/nvl-seo.php:
'owners' => [Article::class],
An API-key-to-class map may retain a distinct API key, for example owners = ['articles.detail' => Article::class]. Preserve mutation authorization and scopes. Core registration does not add the model to this package's allowlist.
Laravel's getMorphClass() determines stored identity. These class declarations do not install host morph maps. Keep resolvers, handlers and authorization independent; use nvl:doctor --strict --format=json to review legacy alias mismatches or stored identity drift. See UPGRADING.md before changing the host's morph map.
Shared consumer diagnostics
Run php artisan nvl:doctor --strict --format=json to combine the read-only checks from loaded NVL package providers. Errors fail the gate, and strict mode also fails warnings. This package's existing Doctor command remains available and uses the same package-owned inspection service.
Next major: isolated schema identities
Use nvl-seo.tables.<logical-key> for every table and nvl-seo.connection for its database connection. Null connection inherits nvl-core.connection, then Laravel's default. Tables are resolved at runtime by the package table definition helper.
| Logical key | New default | Previous name |
|---|---|---|
profiles |
nvl_seo_profiles |
seo_profiles |
i18n |
nvl_seo_i18n |
seo_profiles_i18n |
redirects |
nvl_seo_redirects |
seo_redirects |
redirect_locks |
nvl_seo_redirect_locks |
seo_redirect_locks |
Migration filenames contain nvl_seo_. Existing installations must complete the upgrade in UPGRADING.md before running new migrations. A pending creator rejects an existing target before that owned migration runs; use nvl:schema:preflight for an explicit whole-batch check; legacy storage with old history needs an ownership decision.
Owned cache and lock identities follow nvl:<package>:<purpose>:…. The default sitemap cache prefix is nvl:seo:sitemap instead of nvl-seo:sitemap. An explicitly configured nvl-seo.sitemap.cache_key remains host-selected. Old default entries expire naturally; NVL never copies or removes a generic host entry. See UPGRADING for coordinated worker and lock lease cutover.
Canonical configuration ownership
Use nvl-seo settings in config/nvl-seo.php and canonical package environment names. Old generic roots are foreign unless an upgrading NVL host explicitly selects them in Core's default-off compatibility. Canonical false/null/empty values win; no old roots are populated or written back. Keep logical package/resource IDs unchanged. Review Core's rename inventory and cache/worker cutover.
Testing your app
Inject the supported contract rather than constructing its concrete Action or querying package tables. Replace Nvl\Seo\Contracts\SeoProfileStatusContract in Laravel's native container for a host-workflow test:
use Nvl\Seo\Contracts\SeoProfileStatusContract; $double = Mockery::mock(SeoProfileStatusContract::class); $this->app->instance(SeoProfileStatusContract::class, $double); // Configure the exact execute arguments and documented return value for your host case.
The package's conditional native binding preserves host substitutions. Production uses the real contract; test doubles do not prove its storage/authorization behavior.
A detached fixture for a returned identity/data handle is:
use Nvl\Seo\Models\SeoProfile; $fixture = SeoProfile::factory()->withoutParents()->make();
Ordinary make() may persist declared package parents. withoutParents()->make() disables parent expansion/admission for detached fixtures; use explicit persisted parents/owners and matching effective connections for a real create(). Factories do not authorize workflows, call Stripe, create backing Media objects or publish Template artifacts. Enabled tenancy requires explicit admitted persisted tenants/parents. Your host test installation supplies Faker; no test runner is a runtime package dependency.
Use Laravel Event::fake(), Queue::fake(), Mail::fake() or Storage::fake() only for the effects the host test intends to isolate. Use real commits/listeners for timing proof. Add the optional Core consumer boundary rules to host PHPStan:
includes: - vendor/nvl/core/support/consumer-audit.neon parameters: nvlConsumer: testPaths: [tests] tableNames: [] exceptions: []
Rules read installed public metadata without suite boot. They flag internal symbols, package model queries/writes, capability relations and owned tables; they cannot prove dynamic code or runtime authorization. Exact exceptions require file, identifier, symbol, and a documented reason. The published 5.x family is verified through the local Dagger release gate on PHP 8.4/Laravel 13, including owning suites, MySQL/PostgreSQL persistence contracts and sealed Tenancy consumers. Fresh public Composer installation, discovery and configuration/route caching are verified. PHP 8.5, Laravel 12, MariaDB and the full independent archive matrix require separate evidence. See the verification and release policy.
Shipped factory states
These runtime builders keep Laravel's native Factory API. The listed methods name explicit supported parent/owner/lifecycle states; follow each factory's native admission requirements. Detached examples above do not assert persistence validity.
| Factory | Explicit states |
|---|---|
SeoProfileFactory |
forOwner(Model $owner) |
SeoProfileTranslationFactory |
forProfile(SeoProfile $parent) |
SeoRedirectFactory |
Native Factory states only |
Error codes and events
All recognized package failures implement Nvl\Support\Contracts\PackageException; only RespondableException opts into safe response metadata. Keep native PHP programmer errors and Laravel/SDK exceptions distinct. The optional PackageExceptionRenderer is registered by the host in withExceptions; it leaves unrelated, marker-only and non-JSON handling to the host. Its JSON envelope is {message:string, code:string, context:object}. Request locale is host-owned; diagnostics/previous exceptions are not public copy. Event schemas and source connections are documented in events.
The table lists enum discriminators, including any successful codes retained for compatibility. A code is not itself an HTTP status; the throwing exception's suggestedStatus() is authoritative, especially legacy/custom constructors. Empty context renders as {}; only documented JSON-safe context is presented.
| Code | Suggested status | Public context | Translation key |
|---|---|---|---|
operation_failed |
Exception-defined; see suggestedStatus() |
Declared safe scalar/array map; otherwise {} |
nvl-seo::responsecode.operation_failed |
invalid_seo_mutation |
Exception-defined; see suggestedStatus() |
{errors} | nvl-seo::responsecode.invalid_seo_mutation |
seo_path_conflict |
Exception-defined; see suggestedStatus() |
{scope, locale, path, profileId} | nvl-seo::responsecode.seo_path_conflict |
seo_redirect_loop |
Exception-defined; see suggestedStatus() |
Declared safe scalar/array map; otherwise {} |
nvl-seo::responsecode.seo_redirect_loop |
stale_seo_profile |
Exception-defined; see suggestedStatus() |
{profileId} | nvl-seo::responsecode.stale_seo_profile |
stale_seo_redirect |
Exception-defined; see suggestedStatus() |
{redirectId} | nvl-seo::responsecode.stale_seo_redirect |
License
Released under the MIT License.