Search by

nvl / seo

nicolas_vls

Localized SEO metadata, canonical URLs, social previews, structured data, robots, and sitemaps for Laravel

v6.0.1 2026-10-21 05:11 UTC

This package is auto-updated.

Last update: 2026-10-21 06:51:18 UTC


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.

← NVL Laravel Suite

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/translatable 2.x
  • nvl/core 2.x
  • ext-json, ext-libxml, ext-xmlwriter, and ext-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 WebSite node ({siteUrl}#website);
  • a stable WebPage node ({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 SeoAuthorization are 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.