nvl / metafields
Typed, polymorphic and translatable metafields for Laravel
Requires
- php: ^8.3
- ext-filter: *
- laravel/framework: ^13.0
- nvl/core: ^2.0
- nvl/tenancy: ^2.0
- nvl/translatable: ^2.0
- spatie/laravel-data: ^4.23
- spatie/typescript-transformer: ^3.3
- symfony/http-foundation: ^7.0 || ^8.0
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.27
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-26 07:35:38 UTC
README
For support, open an issue. For vulnerabilities, use private reporting. See Contributing.
See the installation and publishing guide for Composer setup, configuration, migration ownership, and agent skills.
Quick reference
| Item | Value |
|---|---|
| Installed through | composer require nvl/metafields:^2.0 |
| Module identifier | nvl/metafields |
| PHP namespace | Nvl\Metafields |
| Service provider | Nvl\Metafields\Providers\MetafieldsServiceProvider |
| Configuration | config/metafields.php |
Typed, validated, queryable, and optionally localized custom fields for registered Eloquent owners.
Purpose
nvl/metafields supplies source-independent field definitions, owner
assignments, typed values, reference resolution, localized copy, optimistic
concurrency, and a secured optional management API. It is headless and assumes
no application model, identifier type, frontend, or authorization role.
It is not a schema-less database, unrestricted JSON query language, application settings engine, or secret store.
Requirements and dependencies
- PHP 8.4 or newer
- Laravel 13
nvl/corenvl/tenancynvl/translatablenvl/translatable
Package-owned rows use UUID primary keys. Polymorphic owner and referenced identifiers are stored as strings, allowing integer, UUID, ULID, and other stable application keys.
Installation
composer require nvl/metafields:^2.0 php artisan migrate php artisan vendor:publish --tag=metafields-config
Package discovery registers MetafieldsServiceProvider. Migrations load
automatically unless metafields.migrations.enabled is false. Optional
resources are published with:
php artisan vendor:publish --tag=metafields-translations php artisan vendor:publish --tag=metafields-skills
Choose exactly one migration owner. For automatic vendor loading, leave
metafields.migrations.enabled=true and do not publish
metafields-migrations. For host-owned migrations, publish
metafields-migrations with
php artisan vendor:publish --tag=metafields-migrations, set
metafields.migrations.enabled=false before the first migration, and maintain
the copied files as application migrations.
Never run both sources; Laravel retimestamps published migrations.
English and Bulgarian validation copy ships with the package.
Register owners and references
Every owner uses a stable alias and an explicit allowlist:
return [ 'owners' => [ 'articles' => [ 'model' => Domain\Content\Article::class, 'label' => 'Articles', 'supported_types' => [ 'string', 'text', 'rich_text', 'integer', 'decimal', 'boolean', 'date', 'datetime', 'json', 'enum', 'reference', 'reference_list', ], 'sections' => ['content', 'publishing'], 'runtime_status' => 'live', ], ], 'reference_models' => [ 'authors' => Domain\People\Author::class, ], ];
Persisted definitions and polymorphic owner rows store stable aliases, not PHP class names. The owner registry rejects invalid models, types, sections, duplicate models, inheritance-ambiguous owners, conflicting application morph maps, and duplicate or empty aliases. The reference registry also requires one stable alias per model. Reference values must resolve through the reference allowlist, identify an existing record, and pass the consumer-owned reference authorization boundary.
Definition localization
Definition title, description, hint, localized defaults, and presentation
properties live only in metafields_definitions_i18n. Base definition
copy columns are intentionally absent from the clean schema. Applications
adopting an unrelated or pre-package table must move base copy into the
configured locale through their application-owned bridge.
metafields.definitions and metafields.values automatically register with
the central nvl/translatable resource registry.
Create a definition
External input must use validateAndCreate():
use Nvl\Metafields\Actions\MetafieldDefinitions\CreateMetafieldDefinitionAction; use Nvl\Metafields\Data\CreateMetafieldDefinitionPayload; $payload = CreateMetafieldDefinitionPayload::validateAndCreate([ 'namespace' => 'content', 'key' => 'editor_note', 'type' => 'rich_text', 'isTranslatable' => true, 'assignment' => [ 'ownerType' => 'articles', 'section' => 'content', 'isRequired' => false, 'isActive' => true, ], 'translations' => [ 'en' => [ 'title' => 'Editor note', 'description' => 'Localized supporting copy.', ], 'bg' => [ 'title' => 'Бележка на редактора', ], ], ]); $definition = app(CreateMetafieldDefinitionAction::class)->execute($payload);
Translation maps accept title, description, hint, defaultValue, and
properties. Definition copy is always localized. Only value types that
support localization may set isTranslatable.
Updates use UpdateMetafieldDefinitionPayload and require
expectedRevision. Omitted optional definition fields are preserved, explicit
nulls clear nullable fields, and existing localized rows patch only supplied
fields. A title is required only when a new locale is introduced. Shape-changing
updates are rejected while active owner values would become unreadable.
Available definition Actions are:
CreateMetafieldDefinitionActionUpdateMetafieldDefinitionActionArchiveMetafieldDefinitionActionDeleteMetafieldDefinitionActionListMetafieldDefinitionsAction
Types and validation
Supported value types are:
- string, text, and rich text
- integer, decimal, and float
- boolean
- date and date-time
- JSON with a bounded property schema
- array
- enum
- single reference and reference list
- URL and color
The JSON boundary limits encoded bytes, depth, item count, recursion, and
schema properties. JSON definitions require a declared property schema and
cannot use unrestricted custom JSON paths. Non-JSON types may add only
allowlisted Laravel validation rules. The same structured-value limits apply
to array defaults, localized presentation properties, and assignment UI
configuration. Bulk owner synchronization accepts at most
metafields.limits.maximum_sync_items items per request (100 by default).
References are checked for allowed alias, identifier shape, record existence, and consumer authorization before persistence. Raw configurable validation rules cannot perform database queries, network lookups, or arbitrary regular expressions.
Synchronize owner values
SyncOwnerMetafieldsAction is the canonical bulk write path:
use Nvl\Metafields\Actions\Metafields\SyncOwnerMetafieldsAction; use Nvl\Metafields\Data\SyncOwnerMetafieldsPayload; $payload = SyncOwnerMetafieldsPayload::validateAndCreate([ 'items' => [ [ 'definitionId' => $definition->id, 'translations' => [ 'en' => 'Handle with care.', 'bg' => 'Работете внимателно.', ], 'translationMode' => 'patch', 'expectedRevision' => 1, ], ], ]); $values = app(SyncOwnerMetafieldsAction::class)->execute($article, $payload);
Use patch to preserve omitted localized rows and replace to remove them.
Creating a value omits expectedRevision; every update or clear must provide
the current revision. The action acquires current-row locks inside its
transaction, rejects stale revisions, rolls back the entire payload if any item
fails, and dispatches MetafieldsSyncedEvent after commit. Recreating a cleared
value does not require the hidden revision of its soft-deleted storage row.
Focused operations are available through SetMetafieldAction,
DeleteOwnerMetafieldAction, and ListOwnerMetafieldsAction.
Querying
Definitions are indexed by namespace, key, active handle, archive state, and assignment. Values are indexed by definition and polymorphic owner. Query helpers operate on registered definitions and supported scalar values; raw request columns, relations, and arbitrary JSON paths are never accepted.
Use ListAuthorizedOwnerMetafieldsAction for application-facing owner reads:
use Nvl\Metafields\Actions\Metafields\ListAuthorizedOwnerMetafieldsAction; final readonly class ShowArticleEditor { public function __construct( private ListAuthorizedOwnerMetafieldsAction $metafields, ) {} public function __invoke(Article $article, string $locale): array { return $this->metafields->execute($article, $locale)->all(); } }
The Action authorizes MetafieldAbility::ViewOwner before any storage query,
then returns the existing OwnerMetafieldField projection with assignments,
definitions, localized copy, typed values, and reference metadata. Its populated
projection uses at most seven queries whether one or 25 fields are returned.
The result is deliberately uncached because values, definitions, locale, and
authorization are mutation- and request-sensitive.
ListOwnerMetafieldsAction remains the storage-focused composition primitive
used by package adapters. New consumer management reads should use the
authorized Action instead of reproducing authorization or eager loading.
Authorization
All optional HTTP operations call MetafieldAuthorization. Every reference
write calls MetafieldReferenceAuthorization.
ConfiguredMetafieldAuthorization fails closed unless named Gate abilities are
configured. ConfiguredMetafieldReferenceAuthorization also fails closed until
metafields.authorization.reference_ability is configured. Owner mutations may
fall back to the owner's update policy only after the owner has been resolved
from its registered alias.
For application-specific rules, bind the contract:
$app->bind( Nvl\Metafields\Contracts\MetafieldAuthorization::class, Domain\Security\MetafieldAuthorizer::class, ); $app->bind( Nvl\Metafields\Contracts\MetafieldReferenceAuthorization::class, Domain\Security\MetafieldReferenceAuthorizer::class, );
The optional HTTP controllers invoke MetafieldAuthorization before delegating
to mutation Actions. Direct mutation Actions are trusted application-service
operations: they validate definitions, assignments, values, and revisions, but
do not invoke owner or definition authorization. Consumer controllers and other
user-driven compositions must authorize before calling them.
For example, with constructor-injected authorization and Actions:
$this->authorization->authorizeDefinition(MetafieldAbility::CreateDefinition); $definition = $this->createDefinition->execute($definitionPayload); $this->authorization->authorizeOwner(MetafieldAbility::MutateOwner, $owner); $values = $this->syncOwnerMetafields->execute($owner, $valuesPayload);
MetafieldAbility is Nvl\Metafields\Enums\MetafieldAbility. Use
UpdateDefinition for definition updates, archiving, and assignment changes;
DeleteDefinition for definition deletion; and DeleteOwnerValue with the
owner and definition for clearing one owner value. ListAuthorizedOwnerMetafieldsAction
performs its own ViewOwner authorization. Reference values independently pass
through MetafieldReferenceAuthorization inside value validation; permission
to reference a record does not authorize mutation of its owner.
Optional management API
Routes are disabled by default. To enable them:
'routes' => [ 'enabled' => true, 'prefix' => 'api/v1', 'middleware' => ['api'], 'management_middleware' => ['auth', 'throttle:metafields-management'], 'rate_limit_per_minute' => 60, ],
The resulting surface is /api/v1/metafields/... with route names under
nvl.metafields.management.*. It covers definitions, archive/delete,
registered owners, list/read, bulk synchronization, and value deletion.
Every operation is authorized. No UI is included.
Database and adoption
Optional tenant ownership and definition catalogs
Installing Metafields also installs inert nvl/tenancy; the feature remains
disabled until the host explicitly adopts it. Definitions, assignments, and
definition translations share one partition. Values and localized values always
inherit the canonical owner's tenant. A configured class is not authority:
owners and every reference target must resolve through a registered tenant
resource, and unknown classifications fail closed.
With tenancy.sharing.metafields=copy, a platform grant exposes only an exact
scalar definition snapshot. Import requires exact grant/source revisions, an
idempotency fingerprint, an explicit collision-free target handle, and a total
reference map. It creates an ordinary independent tenant definition with copied
locale/default/type/schema data and immutable provenance. Revocation and source
deletion block future imports without changing committed copies or values.
Adopt in maintenance through prepare → bounded backfill → verify → activate. Only source/schema repair consistent with the immutable reviewed mapping may resume. A changed mapping requires the pre-cutover restore or a new reviewed prepare; dropping tenant columns is not a rollback after duplicate handles exist.
The package owns:
metafields_definitionsmetafields_definitions_i18nmetafield_definition_assignmentsmetafieldsmetafields_i18n
Definition and value rows carry integer revisions. Active definition handles are unique, and each owner/definition pair reuses one soft-deletable value row. Owner-first composite indexes support runtime reads. Because the package has no published migration history, clean-install create migrations define the complete schema directly and fail loudly if package-owned table names collide.
Applications adopting existing tables may temporarily set:
'migrations' => ['enabled' => false],
Then inspect without mutation:
php artisan nvl:metafields:doctor --strict --format=json
The doctor checks tables, required columns, ordered index columns and uniqueness, owner and reference registrations, both authorization bindings, and optional route authentication and rate limiting.
Operational commands are:
php artisan nvl:metafields:list php artisan nvl:metafields:definition-add php artisan nvl:metafields:definition-remove content.editor_note php artisan nvl:metafields:doctor --strict
Review UPGRADING.md before adopting an existing schema.
TypeScript
DTOs register with Core's Data provider and generate under Nvl.Metafields.*:
php artisan nvl:data:types:generate php artisan nvl:data:types:check
Mutation DTOs are write contracts. Display DTOs resolve localized copy and never expose arbitrary application model state.
Failure behavior
- Unknown owners, definitions, reference aliases, and records fail closed.
- Stale revisions raise package-specific concurrency exceptions.
- Invalid type changes and oversized or malformed payloads fail before writes.
- Actions own their transactions; success events dispatch after commit.
- APIs remain absent when disabled.
- The package does not swallow database, cast, or reference failures.
Development
composer install composer quality
The isolated Pest suite covers all declared type casts, definition and value localization, patch/replace behavior, references, identifier strategies, authorization, revision enforcement, uniqueness, JSON bounds, management routes, schema diagnostics, and the consumer workflow above. CI runs the stateful package suite on SQLite, PostgreSQL, and MySQL.
See SECURITY.md, UPGRADING.md, CONTRIBUTING.md, and CHANGELOG.md.
License
Released under the MIT License.