Search by

nvl / content

nicolas_vls

Schema-driven, translatable content blocks, placements, media references, and headless rendering for Laravel

v2.2.1 2026-09-25 21:26 UTC

This package is auto-updated.

Last update: 2026-09-26 07:35:37 UTC


README

← 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.

Quick reference

Item Value
Installed through composer require nvl/content:^2.0
Module identifier nvl/content
PHP namespace Nvl\Content
Service provider Nvl\Content\Providers\ContentServiceProvider
Configuration config/content.php

nvl/content is a headless, schema-driven content-block engine for Laravel 12–13 on PHP 8.4+. It provides reusable, translatable blocks, typed fields, validated structured data, semantic rich-content presets, generated JSON Schemas, typed render projections, model-backed owner placements, named composition groups, trees and regions, Media and model references, immutable composition snapshots, and safe Blade starting views.

It is intended for page builders, reusable site sections, email/document content, CMS-like application content, and any workflow that needs an ACF-style field system without coupling content to an administration UI.

Optional tenant ownership

Content is tenant-aware only when nvl/tenancy is explicitly enabled and the content family is adopted. Code definitions remain immutable platform vocabulary; blocks are tenant roots and translations, revisions, placements, Media references, and registered reference results inherit the canonical tenant. Consumer payloads never select tenant_id.

Adopt media and every registered Content-owner family before content. Run prepare/backfill/verify/activate under maintenance, supply reviewed block mappings for existing rows, and let the adopter derive child and placement ownership from canonical parents. Activation replaces natural uniqueness with tenant-leading keys and makes ownership non-null. Format-1 snapshots require an explicit Content::adoptSnapshot() conversion under their verified owner; runtime rendering accepts only tenant-bound format-2 snapshots after adoption.

Public HTTP must resolve the host to TenantSiteContext before Page binding or Content rendering. Workers and publishing commands must enter TenantRunner and install the same verified site context through the host adapter. Never mutate configuration or retain owner, resolver, Media, or reference models in worker singletons.

Purpose

Content provides one reusable contract for defining, validating, localizing, placing, versioning, and rendering structured application content. It replaces consumer-specific block tables and closed enums with source-defined schemas, open adapter registries, DTO/Action boundaries, and explicit integrations.

Architecture and boundaries

Source-controlled definitions are authoritative. They describe available block types, recursive field schemas, defaults, views, scopes, and regions. A database mirror makes definitions queryable and records source hashes; it does not allow executable schemas or Blade source to be edited as database content.

Persisted blocks contain normalized values, dedicated locale rows, a snapshot of the definition schema/view used when they were created, lifecycle state, optimistic revision, and audit actor identifiers. Placements reuse blocks on allowlisted owners and provide stable keys, regions, order, parent-child trees, visibility, and non-localized overrides.

The package is deliberately headless:

  • It does not ship an admin application, page-builder JavaScript, or a consumer model assumption.
  • Binary ownership, associations, delivery, and transformations remain in nvl/media; Content values store Media UUIDs.
  • Locale fallback and the central resource registry remain in nvl/translatable.
  • Reference values use registered aliases and never accept arbitrary model class names.
  • Blade files remain source controlled. Database values are never evaluated as PHP or compiled as Blade.

The dependency graph is:

content
├── data
├── filterable
├── media
├── support
└── translatable

Composer installs these declared dependencies automatically.

Installation

composer require nvl/content:^2.0
php artisan migrate
php artisan nvl:content:definitions:sync --dry-run
php artisan nvl:content:definitions:sync
php artisan nvl:content:definitions:migrate --dry-run
php artisan nvl:content:definitions:migrate
php artisan nvl:content:doctor --strict --format=json

Laravel discovers Nvl\Content\Providers\ContentServiceProvider. Automatic migrations are enabled by default. Existing applications that own compatible tables must disable content.migrations.enabled for that schema before migrating and leave package migration ownership disabled. Bundled migrations fail closed when a target table already exists; they never silently adopt or later drop a pre-existing table.

Optional publish tags are:

php artisan vendor:publish --tag=content-config
php artisan vendor:publish --tag=content-migrations
php artisan vendor:publish --tag=content-views
php artisan vendor:publish --tag=content-skills

Choose exactly one migration owner. For automatic vendor loading, leave content.migrations.enabled=true and do not publish content-migrations. For host-owned migrations, publish content-migrations, set content.migrations.enabled=false before the first migration, and maintain the copied files as application migrations. Never run both sources; Laravel retimestamps published migrations.

Package-owned records use UUID primary keys. Owner, actor, and reference identifiers are strings so consumer models may use integers, UUIDs, ULIDs, or other scalar keys. All table names and the package database connection are configurable.

First working composition

The smallest complete integration needs one owner model, one definition, and an authorization adapter. This system-only adapter is suitable for deployment, seeding, and queue workflows; request-driven applications should replace it with their own user/tenant policy before exposing management routes.

use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Model;
use Nvl\Content\Contracts\ContentAuthorization;
use Nvl\Content\Data\ContentActorData;
use Nvl\Content\Enums\ContentAbility;
use Nvl\Content\Models\ContentBlock;

final class SystemContentAuthorization implements ContentAuthorization
{
    public function authorize(
        ContentAbility $ability,
        ContentActorData $actor,
        ?ContentBlock $block = null,
        ?Model $owner = null,
        array $context = [],
    ): void {
        if (! $actor->system) {
            throw new AuthorizationException('Content access is restricted to system workflows.');
        }
    }
}

Register that adapter, the owner alias, locales, and a source definition in config/content.php:

'authorization' => [
    'class' => App\Content\SystemContentAuthorization::class,
],
'owners' => [
    'page' => App\Models\Page::class,
],
'locales' => [
    'available' => ['en'],
    'required_on_publish' => ['en'],
],
'definitions' => [
    'hero' => [
        'name' => 'Hero',
        'allowed_scopes' => ['global'],
        'allowed_regions' => ['main'],
        'schema' => [
            'fields' => [[
                'key' => 'title',
                'type' => 'text',
                'label' => 'Title',
                'localized' => true,
                'required' => true,
            ]],
        ],
    ],
],

The owner declares its composition groups:

use Illuminate\Database\Eloquent\Model;
use Nvl\Content\Contracts\ContentOwner;
use Nvl\Content\Traits\HasContent;

final class Page extends Model implements ContentOwner
{
    use HasContent;

    public const array CONTENT_GROUPS = ['content'];
}

After synchronizing definitions, the canonical application service supports the complete create, publish, place, and render flow:

use Nvl\Content\Content;
use Nvl\Content\Data\ContentActorData;
use Nvl\Content\Data\Mutations\CreateContentBlockData;
use Nvl\Content\Data\Mutations\PlaceContentBlockData;

$actor = ContentActorData::system();
$content = app(Content::class);
$page = Page::query()->findOrFail($pageId);

$draft = $content->createBlock(
    new CreateContentBlockData(
        definition: 'hero',
        key: 'homepage-hero',
        translations: ['en' => ['title' => 'Welcome']],
    ),
    $actor,
);
$published = $content->publishBlock($draft, $draft->revision, $actor);
$content->place(
    $published,
    $page,
    'content',
    new PlaceContentBlockData(key: 'hero', region: 'main'),
    $actor,
);

$composition = $content->render($page, 'content', 'en', $actor);
$title = $composition->value('hero.title');

Never construct a system actor from request input. For authenticated request workflows, use ContentActorData::fromAuthenticatable($user) and a consumer ContentAuthorization implementation that validates that actor, owner, block, ability, and contextual group/public-render flags.

Configuration reference

Key Default / purpose
connection null; use Laravel’s default connection
tables.* Names for definitions, blocks, blocks_i18n, placements, and revisions
migrations.enabled true; disable only during controlled schema adoption
definition_migrations Sequential ContentDefinitionMigration classes for stored block upgrades
definition_sync.* Cross-process definition synchronization lock lifetime and wait bounds
definition_migration.* Default/maximum atomic batch size and deadlock retry count
authorization.class, authorization.callback Policy adapter and optional callback used by the default adapter
definitions Inline source-authoritative definitions
definition_paths Optional PHP/JSON source files or directories
required_definition_paths Roots that must exist or application boot fails
allowed_definition_roots Real-path boundary for every discovered source
definition_limits.* Maximum discovered files and bytes per source file
scopes Scope alias to validated key_pattern map
owners, references, field_types Allowlisted owner model, reference resolver, and field adapter class maps
presets Consumer semantic preset classes or declarative preset definitions
links.* Safe semantic-link schemes and relative URI policy
locales.available Accepted normalized content locales; empty permits Translatable defaults
locales.required_on_publish Locales that must satisfy required localized fields
validation.* Payload, metadata, reference display, snapshot, revision, schema, depth, item, string, unknown-field, URL-scheme, and JSON-reference limits
rich_text.* Input length, link schemes, and relative-link policy used by Symfony’s sanitizer
media.* Public/private policy, per-field maximum, and private URL lifetime
placements.* Maximum placements/depth plus owner-lock lifetime and wait bounds
rendering.* Default source-controlled view and strict missing-view policy
view_publishing.allowed_roots Absolute destinations permitted for guarded view publication
routes.management.*, routes.public.* Independent enable flags, path/name prefixes, and middleware stacks

Run the strict doctor after publishing or changing configuration. Every configured middleware entry must be a non-empty string. Production applications that cache configuration should bind a ContentAuthorization class rather than storing a Closure callback in cached configuration.

Defining blocks

Definitions may be placed inline in config/content.php, or in sorted *.content.php and *.content.json files under configured content.definition_paths. Paths must resolve beneath content.allowed_definition_roots; traversal and symlink escapes fail closed. Duplicate keys also fail during application boot. Discovery is bounded by content.definition_limits.maximum_files and content.definition_limits.maximum_file_bytes; unreadable or oversized sources fail the boot rather than being partially loaded.

Optional paths may be absent, which keeps the package installable before an application creates resources/content. Put deployment-critical roots in content.required_definition_paths; a missing required root fails boot and prevents an accidental empty scan from orphaning the synchronized mirror.

'definitions' => [
    'marketing.hero' => [
        'name' => 'Marketing hero',
        'description' => 'Reusable hero with localized copy and one image.',
        'category' => 'marketing',
        'version' => 1,
        'view' => 'content.blocks.marketing-hero',
        'allowed_scopes' => ['global', 'site'],
        'allowed_regions' => ['main', 'header'],
        'defaults' => ['enabled' => true],
        'schema' => [
            'fields' => [
                [
                    'key' => 'title',
                    'type' => 'text',
                    'label' => 'Title',
                    'required' => true,
                    'localized' => true,
                    'settings' => ['max_length' => 120],
                ],
                [
                    'key' => 'body',
                    'type' => 'rich_text',
                    'label' => 'Body',
                    'localized' => true,
                ],
                [
                    'key' => 'image',
                    'type' => 'media',
                    'label' => 'Image',
                    'settings' => ['mime_types' => ['image/avif', 'image/webp', 'image/jpeg']],
                ],
                [
                    'key' => 'links',
                    'type' => 'repeater',
                    'label' => 'Links',
                    'settings' => ['max_items' => 4],
                    'fields' => [
                        ['key' => 'label', 'type' => 'text', 'label' => 'Label'],
                        ['key' => 'url', 'type' => 'url', 'label' => 'URL'],
                    ],
                ],
            ],
        ],
    ],
],

A PHP definition file may return one definition, a list, or a map keyed by definition key. JSON files use the same shape. Snake-case definition options are mapped deterministically. This authoring shape is an internal ContentDefinitionSource; after compilation, every public definition uses ContentDefinitionData with a typed recursive ContentSchemaData and ContentFieldDefinitionData tree. Raw source arrays therefore never masquerade as compiled client contracts.

Run synchronization after changing source definitions:

php artisan nvl:content:definitions:sync --dry-run --format=json
php artisan nvl:content:definitions:sync --format=json

The plan reports create, update, unchanged, and orphan keys. Removed source definitions are marked orphaned instead of deleting blocks that still depend on their schema snapshots. Contract changes (schema, defaults, scopes, regions, or view) must increase the definition version; version decreases are rejected. Unknown definition/field properties, conflicting keyed identities or snake/camel aliases, ambiguous booleans, irrelevant child/item declarations, malformed JSON Schemas, invalid defaults, and unregistered reference aliases fail during application boot. External Media/reference defaults are shape-checked at boot and resolved against live authorization and availability when content is written.

Evolving definitions

A definition version is a persisted content contract, not documentation. Whenever a version changes and older blocks exist, register every sequential one-version migration. Synchronization rejects the new mirror when any stored version lacks a complete path.

use Nvl\Content\Contracts\ContentDefinitionMigration;
use Nvl\Content\Data\ContentDefinitionMigrationContextData;
use Nvl\Content\Data\ContentDefinitionMigrationValuesData;

final class MarketingHeroV1ToV2 implements ContentDefinitionMigration
{
    public function definitionKey(): string
    {
        return 'marketing.hero';
    }

    public function fromVersion(): int
    {
        return 1;
    }

    public function toVersion(): int
    {
        return 2;
    }

    public function migrate(
        ContentDefinitionMigrationContextData $context,
    ): ContentDefinitionMigrationValuesData {
        $translations = $context->translations;

        foreach ($translations as $locale => $values) {
            $values['title'] = $values['headline'] ?? null;
            unset($values['headline']);
            $translations[$locale] = $values;
        }

        return new ContentDefinitionMigrationValuesData(
            values: $context->values,
            translations: $translations,
            metadata: $context->metadata,
        );
    }
}

Register migrations in configuration:

'definition_migrations' => [
    MarketingHeroV1ToV2::class,
],

Each step is deterministic and may change only base values, locale values, and metadata. The package replans against current source definitions, locks every target in stable order, verifies the exact planned revision, executes the full chain, validates the result against the final compiled schema, replaces locale rows, validates every dependent placement region and override, resynchronizes Media, records a migrated revision, and dispatches ContentBlockChanged after commit. Any failure rolls back the complete batch. Soft-deleted blocks are upgraded without reattaching Media until restoration.

$plan = $content->planDefinitionMigrations(
    actor: $actor,
    definition: 'marketing.hero',
    limit: 100,
);

if ($plan->blocked === []) {
    $result = $content->applyDefinitionMigrations($plan, $actor);
}

Plans contain identities, versions, and revisions, never content values. updateBlock() and publishBlock() return definition_migration_required/409 for an old block instead of silently stamping it with the latest definition.

Built-in field types

The stable built-in aliases are:

Category Aliases Behavior
Strings text, textarea Length and optional pattern validation
Semantic strings url, uri, email, color, date, date_time Strict normalized semantic validation; url is absolute while uri also supports safe site-relative links
Choices select, multi_select Only configured options; bounded unique lists
Numbers integer, number Strict numeric types with optional minimum and maximum
Flag boolean Boolean values only; no ambiguous string coercion
Rich text rich_text Sanitized at mutation and returned as an explicit safe render DTO
Structured object, list, repeater, table Recursive child/item schemas and bounded depth/items
Arbitrary JSON json Opis JSON Schema Draft 2020-12 validation
Media media, media_collection Available Media UUIDs, MIME/visibility/ownership policy, safe display DTOs
References reference, reference_list Allowlisted resolver aliases and bounded IDs

Repeaters receive stable _key values during normalization. This lets a headless editor reorder or patch rows without treating the current array index as identity. Localized structural repeater rows must send an existing base _key; reordered and partial locale rows remain supported without positional guessing. Arrays replace as a unit in patch mode; nested JSON objects use merge-patch semantics.

Structured fields recurse through fields or an item definition. The global payload, depth, field-count, item-count, metadata, string, and snapshot limits are configurable and enforced before persistence or rendering. Unknown fields fail by default. Built-in field settings are also allowlisted and validated at boot—including lengths, item counts, numeric bounds, options, regular expressions, URL schemes, MIME types, and reference aliases—so misspelled schema options cannot remain dormant until production content is edited. The javascript, data, file, and vbscript schemes are always denied, including in rich text and consumer-provided allowlists. Global scheme configuration is validated during provider registration and field-specific overrides are validated with their definitions.

The json field requires a schema in settings.schema. Remote $ref values are denied by default. Enabling remote references should only be considered with a consumer-owned resolver and explicit network/security policy.

Custom types implement Nvl\Content\Contracts\ContentFieldTypeAdapter and are registered by alias in content.field_types. Registration rejects mismatched or duplicate aliases. An adapter may additionally implement ContentFieldDefinitionValidator to reject invalid type-specific settings at application boot rather than waiting for the first mutation.

Semantic rich-content presets

Presets are reusable semantic schemas built on the same bounded field system. A source definition references the semantic alias; Content expands it to a complete recursive schema before validation and persistence:

'homepage.banner' => [
    'name' => 'Homepage banner',
    'category' => 'marketing',
    'version' => 1,
    'allowed_scopes' => ['site'],
    'schema' => [
        'fields' => [
            [
                'key' => 'banner',
                'preset' => 'banner',
                'label' => 'Banner',
                'required' => true,
            ],
        ],
    ],
],

The compiled field retains preset => 'banner' as its semantic hint and contains ordinary object, media, rich_text, select, and other child fields. Consumers therefore have one validation, persistence, translation, Media-association, snapshot, and rendering path rather than a separate runtime for special blocks.

The built-in presets are:

Alias Semantic value
link Localized label/title, safe internal or external destination, target, and allowlisted relationship tokens
button A link plus semantic primary, secondary, or tertiary emphasis
image Image Media plus localized alt, title, rich caption, credit, decorative state, and focal point
heading Localized eyebrow, title, rich description, and semantic h1–h6 level
banner Heading, image, primary/secondary buttons, and direction-aware alignment

Rendered preset values are typed as RenderedContentLinkData, RenderedContentButtonData, RenderedContentImageData, RenderedContentHeadingData, and RenderedContentBannerData. Closed semantic choices use backed enums such as ContentLinkTarget, ContentHeadingLevel, and ContentAlignment; Media and sanitized rich text keep their existing safe DTO projections.

Every registered preset is compiled and validated during application boot, including presets that no definition currently uses. Consumer presets may implement ContentFieldPreset or be declared under content.presets. Definition fields may override presentation metadata, defaults, and settings, but cannot replace a preset's type, fields, or item structure. Custom normalization receives one base or locale partition at a time; ContentValidationContext::$localized identifies which partition is active. It must return JSON-safe normalized data. validate() receives each complete, locale-resolved value after schema-aware fallback, so cross-field semantic invariants do not have to inspect storage partitions. jsonSchema() adds the same constraints to the editor contract. Rendering receives that merged value and may project it to a typed DTO.

The built-in image preset uses these hooks to require non-empty, locale-resolved alt text when a non-decorative image is published. Decorative images intentionally render with an empty alt attribute. Link and button destinations reject unsafe schemes, protocol-relative values, control characters, credential-bearing HTTPS URLs, and backslashes in relative URIs.

Content::presets($actor) and ListContentPresetsAction return the complete editor catalog. Preset field values are typed ContentFieldDefinitionData objects, not arrays. Each preset and each ContentDefinitionData also includes a JSON Schema Draft 2020-12 document with x-content-type, x-content-localized, and x-content-preset annotations. This is the canonical contract for API clients and generic form builders; Filament remains a consumer integration. The generated document describes complete, locale-resolved values for a publish check. Draft mutations may omit required fields; publication still uses the server validator for locale, Media, reference, and semantic rules. Local $ref values inside a json field's schema retain their field-local meaning in the generated document.

Localization

Set localized => true on any leaf or subtree whose values vary by locale. Localized values are stored in content_blocks_i18n; structural/base values stay on the block. A non-localized object may contain localized descendants, so an image UUID, link destination, target, heading level, or layout choice can stay canonical while its alt text, label, caption, or title translates. Locale normalization and fallback are delegated to nvl/translatable.

$block = $create->execute(
    new CreateContentBlockData(
        definition: 'homepage.banner',
        key: 'homepage-banner',
        scope: 'site',
        scopeKey: 'main',
        values: [
            'banner' => [
                'image' => ['media' => $media->id],
                'primary_action' => ['href' => '/donate'],
                'alignment' => 'center',
            ],
        ],
        translations: [
            'en' => [
                'banner' => [
                    'heading' => ['title' => 'Build something useful'],
                    'image' => ['alt' => 'Volunteers preparing donations'],
                    'primary_action' => ['label' => 'Donate now'],
                ],
            ],
            'bg' => [
                'banner' => [
                    'heading' => ['title' => 'Създайте нещо полезно'],
                    'primary_action' => ['label' => 'Дарете сега'],
                ],
            ],
        ],
    ),
    $actor,
);

Configure available locales and locales required for publication under content.locales. Every Content locale must also be registered in translatable.locales, which remains the canonical locale runtime. Publishing validates the complete schema again, including required localized fields and Media/reference availability. Locales listed in required_on_publish must contain every required localized value; other locales may remain partial, and every supplied value is still validated. An empty required_on_publish list requires all available locales. When content.locales.available is empty, Content uses the Translatable locale registry. Locale aliases that normalize to the same key are rejected instead of silently overwriting one another. HTTP rendering defaults to the request-scoped ContentLocale, not Laravel's UI locale.

Nested fallback is schema-aware and resolves leaf by leaf. In the example, a Bulgarian render keeps the base Media and destination, uses the Bulgarian heading/button copy, and may fall back to the English image alt without replacing the surrounding image object. Repeater translations match stable base _key values; localized projections cannot create unrelated base rows or objects.

content.blocks is registered in the Translatable resource registry so a consumer may gather and manage all localized package resources through one authorization boundary.

Owners, groups, scopes, placements, and trees

Scopes describe where a block may be selected; owners describe what receives a composition. Each scope has a key pattern. The built-in global scope uses *. Applications can add site, tenant, channel, brand, or other opaque scope keys.

Owner aliases map directly to Eloquent models implementing Nvl\Content\Contracts\ContentOwner. Use the supplied Nvl\Content\Traits\HasContent trait to provide the polymorphic relationship and hard-delete cleanup:

Provider integrations should depend on Nvl\Content\Contracts\ContentOwnerRegistrar, not the concrete owner registry.

'owners' => [
    'page' => Acme\Models\Page::class,
],
final class Page extends Model implements ContentOwner
{
    use HasContent;

    public const array CONTENT_GROUPS = [
        'content',
        'navigation',
    ];
}

A group is a named composition partition on one owner. Placement keys, trees, limits, locks, live renders, and snapshots are isolated per group. Regions remain layout slots inside a group, while a definition’s category remains an editor-catalog grouping. This permits the same owner to expose independent surfaces such as content, navigation, and email without key collisions. Every owner declares at least one valid group through CONTENT_GROUPS, or a single group through CONTENT_GROUP. Group discovery returns this declaration, including groups with no placements, and every placement or render rejects an undeclared group.

Place a block through the canonical model-first application surface:

$placement = $content->place(
    $block,
    $page,
    'content',
    new PlaceContentBlockData(
        key: 'hero',
        region: 'main',
        sortOrder: 10,
    ),
    $actor,
);

Placement validation enforces the block definition’s regions, owner existence, parent ownership, a single region throughout each subtree, maximum placement count per group, maximum tree depth, cross-group parent rejection, and cycle prevention. A placement may contain bounded non-localized overrides. Localized copy must remain in the block’s locale rows. Hidden ancestors suppress their complete subtree; a visible child is never promoted when its parent is hidden, private, unpublished, missing, or otherwise ineligible. Placement create/update/delete operations are serialized with an atomic cache lock keyed by owner and group, including the empty-tree case that a database row lock cannot protect.

Publishing and updating blocks revalidate every dependent placement's final values with its owner and group context. Retained placements on soft-deleted owners remain validated without making those owners publicly resolvable.

Nvl\Content\Content::render() loads a complete owner composition with stable ordering, validates its complete tree before rendering, groups root blocks by region, resolves locale values, converts Media IDs to safe Media DTOs, and converts reference IDs through their registered display resolver. Each live or snapshot render owns an isolated resource cache: Media and its display relations are batch-loaded once for the composition, and repeated reference display lookups are memoized without leaking state between requests or queued jobs.

Remove placements explicitly; deleting a block while any placement exists is rejected, and a parent placement cannot be removed before its children:

$unplace->execute(
    placement: $placement,
    expectedRevision: $placement->revision,
    actor: $actor,
);

Media and references

Public Content may reference only reusable public Media by default. Private Content may reference private Media when enabled, but mutation requires the configured Media authorization/uploader policy. Content creates and removes associations only through Media Actions.

Media referenced by placement overrides is associated with the placement UUID, independently of block Media. The provider registers the stable nvl-content-placement morph alias and preserves explicit consumer mappings. Override edits, block replacement, definition migration, unplacement, and owner hard deletion synchronize these usages. Soft deletion preserves them. Hard-deleting an owner through HasContent rolls back the owner, placements, and usages together if cleanup fails or a placement deletion is vetoed. The owner and Content must share the same named connection for this cleanup, and Content and Media association writes must share a named connection. Consumer delete() overrides must delegate to the trait's deletion boundary.

Upgrading existing placement Media references

Pre-existing override references need resynchronization before their Media usage records provide deletion protection. For each live owner and declared group, reapply the current placement DTO values through the canonical boundary with an authorized actor:

use Nvl\Content\Data\Mutations\UpdateContentPlacementData;

foreach ($content->placements($owner, $group, $actor) as $placement) {
    $content->updatePlacement(
        $placement->id,
        new UpdateContentPlacementData(
            expectedRevision: $placement->revision,
            region: $placement->region,
            parentId: $placement->parentId,
            sortOrder: $placement->sortOrder,
            isVisible: $placement->isVisible,
            overrides: $placement->overrides,
        ),
        $actor,
    );
}

Each update validates, synchronizes usages, increments the placement revision, and emits its normal update event. Resolve validation errors and reload stale revisions before retrying. For retained placements on soft-deleted owners, an empty Patch through Content::updateBlock() on their non-deleted block, using its ID and current revision, resynchronizes every dependent placement and records a normal block update. No bulk backfill runs automatically.

At render time:

  • Public files become Nvl\Media\Data\Display\PublicMedia projections.
  • Private files require the Media download policy and become Nvl\Content\Data\RenderedPrivateMediaData with temporary signed URLs.
  • Missing, quarantined, deleted, or otherwise unavailable files are omitted.
  • Disk names, internal paths, digests, quarantine details, and uploader identifiers are never exposed by the public projection.

The Media download policy receives the resolved registered owner for both live and snapshot rendering, so tenant/owner-aware policies do not have to infer context from a URL.

References use a configured reference_type setting and a resolver implementing Nvl\Content\Contracts\ContentReferenceResolver. That resolver owns existence, authorization, and locale-aware display data. Raw PHP class names are never accepted from content input. Resolver display payloads are JSON-only, byte/item/depth/string bounded, cached per composition, and cannot replace the reserved stable id key. Both resolver methods receive ContentValidationContext, including actor, normalized locale, owner, group, visibility, field path, and public/preview mode.

Localized Media fields create Media associations with the normalized locale. Because Content and Media writes form one logical transaction, any block that contains or previously contained Media references must use the same named database connection as Media. nvl:content:doctor --strict verifies this. Private URL lifetime is configured with content.media.private_url_ttl_minutes.

Rendering and Blade starting views

Render headlessly:

$composition = $renderer->render(
    owner: $page,
    group: 'content',
    locale: 'en',
    actor: $actor,
);

$title = $composition->value('hero.title');
$firstTitle = $composition->firstValue('title');

The bundled anonymous components are:

<x-nvl-content::composition :composition="$composition" />
<x-nvl-content::block :block="$block" />
<x-nvl-content::field :value="$value" />

Definitions may choose a source-controlled view. Strict view mode rejects a missing configured view. Rich text is sanitized on mutation and represented by Nvl\Content\Data\RenderedRichTextData; only the bundled explicit rich-text branch emits its already-sanitized HTML without escaping.

Publish the starting views to the default or another allowlisted directory:

php artisan nvl:content:views:publish
php artisan nvl:content:views:publish --path=resources/views/content --force

The destination must remain under content.view_publishing.allowed_roots. Traversal, symlink escape, non-directory targets, and accidental replacement without --force fail or skip safely.

Immutable composition snapshots

Versioning consumers such as nvl/templates use Nvl\Content\Content::capture() and renderSnapshot(). Capture stores a bounded, canonical, JSON-safe composition containing definition schemas/views, normalized base and locale values, placement tree facts, overrides, and Media or reference IDs. Every entry is a ContentCompositionSnapshotBlockData containing a typed ContentSchemaData; array hydration restores the same nested DTO graph. The snapshot includes a SHA-256 integrity version. Capture holds the owner-group mutation lock and locks placement and block rows. With publishing: true, it validates the merged placement values, freezes that normalized final payload in values, and stores an empty overrides map. This lets overrides satisfy required fields and preserves publication-specific normalization. Existing snapshots with separate overrides remain renderable. Consumers that persist the snapshot can use Nvl\Content\Casts\ContentCompositionSnapshotCast to keep the Eloquent attribute typed as ContentCompositionSnapshotData rather than repeatedly hydrating untyped arrays.

Later edits to live blocks or placements do not alter the snapshot. Rendering a snapshot verifies its hash, owner type, owner identifier, record count, payload size, parent existence, region consistency, depth, and cycle freedom, then resolves current Media/reference delivery. This preserves published copy while still enforcing current binary lifecycle and access decisions.

Do not use a live Content composition as the source of a legally or operationally immutable template version; capture it at publication. The hash detects corruption and owner substitution in trusted stored data; it is not a MAC or digital signature. Do not accept caller-supplied snapshots as authentic without a consumer-owned signature and trust boundary.

Public application surface and DTOs

Model-first application code, package integrations, the facade, commands, and optional HTTP controllers use Nvl\Content\Content as the canonical compatibility boundary. Undocumented Actions and services remain implementation units behind that surface. The focused DTO-first editor Actions documented below are intentional public injection seams where the compatibility service does not expose an equivalent method:

Capability Content method Input and concurrency
Discover semantic presets presets() actor; returns compiled fields and JSON Schemas
Discover editor schemas definitions() actor; returns active ContentDefinitionData values
Discover declared groups groups() owner model and actor; includes empty groups
Inspect placement facts placements() owner model, group, and actor; includes hidden placement revisions
Bootstrap an editor editor() owner model, group, and actor; definitions, presets, groups, and placements
Synchronize sources syncDefinitions() actor and dry-run flag
Plan/apply definition upgrades planDefinitionMigrations(), applyDefinitionMigrations() bounded plan followed by exact atomic application
Browse/read blocks blocks(), block() allowlisted FilterSet, actor
Create/edit createBlock(), updateBlock() typed mutation DTO; updates require the exact revision
Publish/archive/delete/restore corresponding block methods expected revision; restore returns a draft
Place/edit/remove place(), updatePlacement(), deletePlacement() typed placement DTOs and expected revision
Render live render() owner model, group, normalized locale, actor, public policy
Capture/render immutable capture(), renderSnapshot() owner model/group or ContentCompositionSnapshotData, actor

UpdateContentBlockData defaults to ContentMutationMode::Patch, preserving omitted values, translations, and metadata. Replace is explicit and removes omitted base values, locale rows, and metadata after schema validation. Lists replace as units; objects are recursively patched. Use an exact expected revision for every editable persisted resource.

Constructor-inject Nvl\Content\Content for its documented model-first operations. Inject a documented focused editor Action when that DTO-first workflow is not present on the service. Nvl\Content\Facades\Content is a static proxy to the service surface for concise Laravel application code; it is not a second execution path. Every existing-resource transition requires the exact revision.

Editor projections

New editor UIs should consume the package-owned projection instead of querying placement, block, definition, or translation tables themselves. Inject the focused Action when editor composition is the caller's only Content concern:

use Nvl\Content\Actions\GetOwnerContentEditorAction;
use Nvl\Content\Data\ContentActorData;

$editor = app(GetOwnerContentEditorAction::class)->execute(
    $page,
    'homepage',
    ContentActorData::fromAuthenticatable($user),
);

Applications already using the canonical service or facade receive the same DTO and authorization behavior:

use Nvl\Content\Facades\Content;

$editor = Content::editor($page, 'homepage', $actor);

ContentEditorData contains deterministically ordered definitions, presets, declared groups, placement DTOs with their editable ContentBlockData, and placementLimit, the validated content.placements.maximum_per_group ceiling the UI must enforce. The placed block projection includes its definition key, lifecycle state, base values, localized values, metadata, and revisions, so a consumer does not navigate placement.block.definition or placement.block.translations itself.

For a page or dashboard index, inject the bounded Action and batch placement facts rather than eager-loading Content relations in consumer code:

use Nvl\Content\Actions\ListOwnerContentPlacementSummariesAction;

$placementsByOwner = app(ListOwnerContentPlacementSummariesAction::class)
    ->execute($pages, 'homepage', $actor);

The bulk projection deduplicates and authorizes every owner before storage queries, consumes at most 100 persisted owner entries, preserves input identity order, and enforces the configured per-group placement ceiling before mapping DTOs. Its non-numeric keys use <owner-type>:<owner-id>; for example, page:01H... and account:42. This keeps JSON object shape stable for UUID, ULID, string, and integer owner IDs and permits different owner types to share the same raw ID. Populated reads use five fixed queries for one or 25 owners of the same registered type: owner existence, placements, blocks, definitions, and translations. Empty input returns [] without querying.

The consumer's ContentAuthorization adapter must treat ContentAbility::ListPlacements with context.includes_blocks=true as permission to disclose the editable blocks placed on that authorized owner. The ordinary model-returning placement call keeps its original exact ['group' => $group] context and does not preload blocks.

Content::placements() remains a documented 1.x compatibility API returning identity-bearing ContentPlacement models. New editor and index reads should use Content::editor(), GetOwnerContentEditorAction, or the bounded bulk DTO projection so consumers do not own package query graphs or lazy relations.

Placement editor workflows

Consumer editor mutations should inject the focused DTO-first Actions instead of resolving ContentPlacement or ContentBlock models in application code:

use Nvl\Content\Actions\FindContentBlockByKeyAction;
use Nvl\Content\Actions\FindContentPlacementAction;
use Nvl\Content\Actions\ReorderContentPlacementsAction;
use Nvl\Content\Actions\ReplaceContentPlacementAction;
use Nvl\Content\Data\Mutations\ReorderContentPlacementData;
use Nvl\Content\Data\Mutations\ReorderContentPlacementsData;

$block = app(FindContentBlockByKeyAction::class)->execute('homepage-hero-v2', $actor);
$placement = app(FindContentPlacementAction::class)->execute(
    $page,
    'homepage',
    'hero-slot',
    $actor,
);

$replacement = app(ReplaceContentPlacementAction::class)->execute(
    owner: $page,
    group: 'homepage',
    placement: $placement->id,
    block: $block->id,
    expectedRevision: $placement->revision,
    actor: $actor,
);

$editor = app(ReorderContentPlacementsAction::class)->execute(
    $page,
    'homepage',
    new ReorderContentPlacementsData([
        new ReorderContentPlacementData(
            id: $replacement->id,
            expectedRevision: $replacement->revision,
            region: 'main',
            parentId: null,
            sortOrder: 0,
        ),
    ]),
    $actor,
);

FindContentBlockByKeyAction requires one exact, unambiguous key across active scopes and returns the editable block DTO. FindContentPlacementAction resolves an exact placement ID or key only inside the supplied registered owner and declared group. It rejects ID/key collisions and returns the constrained nested block projection after ListPlacements authorization with context.includes_blocks=true. Key matching is byte-exact on every supported database. Non-UUID placement keys are never compared with the UUID primary-key column; UUID-shaped input checks both identities so collisions remain explicit.

Replacement locks the complete owner/group placement set and replacement block inside one three-attempt transaction. It authorizes Place, checks the exact placement revision, revalidates existing overrides and tree coordinates against the replacement definition, and changes only the block identity and revision. Soft-deleted blocks are unavailable. Its Place context includes replaces_placement=true.

Reorder input is a complete-set contract: supply exactly one item for every placement in the group, including unchanged rows, up to the configured placement ceiling. The Action authorizes every placed block before comparing all revisions or writing, validates the proposed parent graph entirely in memory, then updates changed rows in deterministic ID order. Cycles, missing/foreign parents, cross-region parentage, excessive depth, duplicate IDs, partial sets, and stale revisions fail atomically. Only changed rows increment once and emit one after-commit ContentPlacementChanged event. Its Place context includes reorders_placements=true, and the return value is the fresh CR-13 editor DTO.

These newer workflows are intentionally Action-injected rather than appended to the Content service/facade. That keeps the original public service constructor callable without optional dependencies that fail at runtime or a hidden service locator. Existing model-returning placement methods remain unchanged for 1.x compatibility.

Generated PHP and TypeScript contracts

Core's Data component is the DTO and PHP-to-TypeScript boundary. The Content service provider registers its source directory automatically. Generate and verify the application-wide declarations with:

php artisan nvl:data:types:generate
php artisan nvl:data:types:manifest
php artisan nvl:data:types:check

The generated Nvl.Content namespace includes recursive compiled definitions, preset fields, editor bootstrap data, definition migration plans/results, mutation inputs, enums, rendered semantic values, and named composition snapshot blocks. Dynamic, definition-specific values, translations, and overrides remain Record<string, unknown> by design; the runtime definition and its JSON Schema are the authority for those application-authored keys. Internal definition sources and server-side migration payloads are hidden from TypeScript generation.

Filament and other editor integrations

Content is intentionally editor-neutral. A consumer-owned Filament resource or page can bootstrap through one call:

$editor = $content->editor($page, 'homepage', $actor);

ContentEditorData contains the compiled definitions, semantic presets, declared groups, selected group, the placement ceiling, and revision-bearing placements with their complete editable block DTOs. A recursive Filament field factory should select a component by field.preset first, then fall back to field.type; nested fields and item values use that same function. The definition JSON Schema remains the client validation contract. Mutations go back through the typed block and placement DTOs with exact revisions. The package never requires Filament and never stores panel or form component classes in schemas.

Authorization and routes

Nvl\Content\Contracts\ContentAuthorization is invoked for all reads, mutations, placements, rendering, and snapshots. The configured adapter denies non-system callers unless content.authorization.callback explicitly returns true. Bind a consumer policy adapter for larger applications. An adapter may also implement Nvl\Content\Contracts\ContentBlockQueryScope to apply actor/tenant constraints to block catalog queries before caller-controlled filters and pagination. Create/update policy contexts include proposed lifecycle state, and promoting an already-published block from private to public additionally requires the Publish ability.

Management and public routes are independently disabled by default. If enabled, each has configurable prefix, route-name prefix, and middleware:

'routes' => [
    'management' => [
        'enabled' => true,
        'prefix' => 'api/v1/content',
        'name' => 'nvl.content.management.',
        'middleware' => ['api', 'auth:sanctum', 'throttle:60,1'],
    ],
    'public' => [
        'enabled' => true,
        'prefix' => 'api/v1/content',
        'name' => 'nvl.content.public.',
        'middleware' => ['api', 'throttle:120,1'],
    ],
],

Routes are thin adapters over the same DTO/Action authorization boundary. Enabling a route is not a replacement for a correct authorization binding. Invalid middleware entries fail closed instead of being silently discarded.

When management routes are enabled they expose:

Method Path below the configured management prefix Route suffix
GET /presets presets.index
GET /definitions definitions.index
GET /owners/{ownerType}/{ownerId}/groups groups.index
GET /owners/{ownerType}/{ownerId}/groups/{group}/placements placements.index
GET /owners/{ownerType}/{ownerId}/groups/{group}/editor editor.show
GET /owners/{ownerType}/{ownerId}/groups/{group}/preview compositions.preview
GET, POST /blocks blocks.index, blocks.store
GET, PUT, PATCH, DELETE /blocks/{block} blocks.show, blocks.update, blocks.destroy
POST /blocks/{block}/publish blocks.publish
POST /blocks/{block}/archive blocks.archive
POST /blocks/{block}/restore blocks.restore
POST /owners/{ownerType}/{ownerId}/groups/{group}/blocks/{block}/placements placements.store
PUT, PATCH, DELETE /placements/{placement} placements.update, placements.destroy

The management placement listing includes hidden rows so an editor can repair or unplace them; preview still honors placement visibility but may render draft/private blocks after authorization. The Render policy receives context.public_only so a consumer can distinguish public delivery from management preview.

The public route group provides GET /owners/{ownerType}/{ownerId}/groups/{group}/composition as compositions.show. Mutation payloads use the documented camel-case DTO field names. Controller validation rejects invalid identifiers, enum values, revisions, tree order, and payload shapes before an Action executes. The package controllers convert transport-neutral ContentException failures and semantic InvalidArgumentException input failures to stable JSON; stale revisions produce stale_content/409, old definition versions produce definition_migration_required/409, and invalid content produces invalid_content/422. They do not intercept authorization or unexpected infrastructure failures.

Persistence, concurrency, and events

Tables are:

  • content_definitions: synchronized source metadata and hashes.
  • content_blocks: base values, schema/view snapshot, scope, status, revision, and actor facts.
  • content_blocks_i18n: one locale/value object per block.
  • content_placements: owner/group tree, region, key, order, visibility, overrides, and revision.
  • content_revisions: bounded immutable mutation snapshots.

All mutations own explicit transactions. Definition synchronization is serialized by a package-wide atomic cache lock and replanned after database row locks, including during rolling deployments. Update, publish, archive, delete, placement update, and placement removal operations require the exact expected revision. Placement rows are locked for tree mutations, deadlocks are retried, and block deletion is refused until the reusable block has no placements. Stale writes raise Nvl\Content\Exceptions\StaleContentException. Nvl\Content\Events\ContentBlockChanged and Nvl\Content\Events\ContentPlacementChanged dispatch after commit. Placement events carry placement, block, owner, group, revision, event, and actor identity, including after deletion. Definition migrations use the migrated block event and revision snapshot without including content values in operational plans or events.

Deletion is a soft tombstone that detaches Content-managed Media associations and preserves the unique scope/key identity. RestoreContentBlockAction restores the block as a draft, revalidates/re-attaches every currently available authorized Media reference, records a new revision, and rolls the whole restore back if those links cannot be re-established. Archive is the preferred reversible way to remove a block only from public delivery.

Commands

# Read-only installation/configuration diagnostics
php artisan nvl:content:doctor
php artisan nvl:content:doctor --strict --format=json

# Definition plan and synchronization
php artisan nvl:content:definitions:sync --dry-run
php artisan nvl:content:definitions:sync --format=json

# Stored-value migration plan or one atomic batch
php artisan nvl:content:definitions:migrate --dry-run --format=json
php artisan nvl:content:definitions:migrate --definition=marketing.hero --limit=100

# Guarded Blade starting-view publication
php artisan nvl:content:views:publish
php artisan nvl:content:views:publish --path=resources/views/content --force

--strict makes the doctor return a non-zero code for an unhealthy schema, missing columns, incompatible index semantics, missing/incorrect foreign keys, stale definition mirror, invalid route configuration, missing default view, incompatible Content/Media connection, or missing authorization binding. It also rejects persisted placements whose owner alias or group is no longer declared and verifies atomic definition-sync and placement-lock support. Pending block versions or missing migration paths are unhealthy. Both definition dry runs are read-only.

Operational guidance

  • For a version change, deploy the migration classes, synchronize definitions, dry-run and apply bounded migration batches, then require a healthy strict doctor before accepting editor writes.
  • Keep route and config caches in CI; providers are deterministic and do not generate artifacts from HTTP requests.
  • Keep Media transformation/scanning queues running if block schemas accept uploads managed by Media.
  • Use eager-rendered composition DTOs at transport boundaries; do not expose Eloquent models or raw schema snapshots.
  • Keep private delivery behind Media authorization and temporary signatures.
  • Back up package tables before schema adoption and run the doctor before and after cutover.

Development and verification

composer validate --strict
vendor/bin/pint --format agent
vendor/bin/phpstan analyse --level=max
vendor/bin/pest --compact
php artisan nvl:data:types:check

The package suite covers clean schema installation, source synchronization, atomic definition migrations and stale plans, translation fallback, rich-text sanitization, JSON Schema validation, repeaters, Media associations and ownership, references, publication, placements, editor bootstrap data, hidden-subtree pruning, snapshot integrity and malformed trees, typed snapshot hydration, preset publication invariants, rendering, Blade components, optimistic concurrency, facade lifecycle coverage, route defaults, bounded definition discovery, generated contracts, and diagnostics.

License

NVL Content is open-sourced under the MIT License.