nvl / taxonomy
Hierarchical, attachable and translatable taxonomies for Laravel
Requires
- php: ^8.4
- ext-ctype: *
- laravel/framework: ^12.0 || ^13.0
- nvl/core: ^6.0.1
- nvl/translatable: ^6.0
- spatie/laravel-data: ^4.23
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.27
- mockery/mockery: ^1.6
- nvl/ownership: ^6.0.1
- nvl/tenancy: ^6.0
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- spatie/typescript-transformer: ^3.3
Suggests
- nvl/ownership: Adds optional model-declared ownership, isolation, adoption and captured worker contexts.
- nvl/tenancy: Temporary legacy tenancy bridge for existing tenant installations; native ownership does not require it.
- spatie/typescript-transformer: Consume the declared TypeScript attributes and metadata (^3.3); Core retains the runtime transformation engine.
Provides
None
Conflicts
- nvl/ownership: <6.0.1 || >=7.0
- nvl/tenancy: <6.0 || >=7.0
Replaces
None
README
Quickstart
composer require nvl/taxonomy:^6.0 php artisan nvl:install taxonomy --dry-run php artisan nvl:install taxonomy
Required NVL dependencies: nvl/core (^6.0), nvl/translatable (^6.0). Register the categories vocabulary and host authorization/owner capability. Use batched owner readers rather than querying package-owned capability relations.
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\Taxonomy\Contracts\TaxonomyTreeContract in a host service. After supplying the trusted inputs described above, the first public call is:
use Nvl\Taxonomy\Contracts\TaxonomyTreeContract; /** @var TaxonomyTreeContract $capability */ $result = $capability->for('categories');
Use the event catalog and Testing your app below. The suite getting-started guide provides a complete Comments host fixture; package archives retain their own local references.
For support, open an issue. For vulnerabilities, use private reporting. See Contributing.
See the installation and publishing guide for Composer setup, configuration, migration ownership, and agent skills.
Quick reference
| Item | Value |
|---|---|
| Installed through | composer require nvl/taxonomy:^6.0 |
| Module identifier | nvl/taxonomy |
| PHP namespace | Nvl\Taxonomy |
| Service provider | Nvl\Taxonomy\Providers\TaxonomyServiceProvider |
| Configuration | config/nvl-taxonomy.php |
Purpose
nvl/taxonomy provides reusable translated vocabularies and hierarchical terms for Laravel 12–13 on PHP 8.4+. It supports categories, tags, ordered trees, typed metadata, polymorphic owner attachment, moves, merges, pruning, and deterministic localized display copy. It is not an arbitrary attribute, facets, or search engine.
The package depends on nvl/core, nvl/tenancy, and nvl/translatable inside the NVL family.
Requirements and installation
composer require nvl/taxonomy:^6.0 php artisan migrate
Laravel auto-discovers TaxonomyServiceProvider. Optional publish tags are:
php artisan vendor:publish --tag=nvl-taxonomy-translations php artisan vendor:publish --tag=nvl-taxonomy-config php artisan vendor:publish --tag=nvl-taxonomy-migrations php artisan vendor:publish --tag=nvl-taxonomy-skills
Clean-install migrations use UUID term identifiers, nullable UUID parent identifiers, dedicated term translations, and string-compatible owner identifiers. Set nvl-taxonomy.migrations.enabled=false during controlled adoption of existing tables.
Choose exactly one migration owner. For automatic vendor loading, leave
nvl-taxonomy.migrations.enabled=true and do not publish nvl-taxonomy-migrations.
For host-owned migrations, publish nvl-taxonomy-migrations, set
nvl-taxonomy.migrations.enabled=false before the first migration, and maintain
the copied files as application migrations. Never run both sources; Laravel
retimestamps published migrations.
Register vocabularies and owners
Declare stable vocabulary rules in config/nvl-taxonomy.php:
'taxonomies' => [ 'topics' => [ 'model' => \Nvl\Taxonomy\Models\Term::class, 'hierarchical' => true, 'exclusive' => false, 'open' => true, 'max_depth' => 5, 'sort' => 'position', 'allowed_owners' => ['articles'], 'metadata_rules' => [ 'color' => ['nullable', 'string', 'max:20'], ], ], ], 'owners' => [ 'articles' => Article::class, ],
TaxonomyRegistry and TaxonomyOwnerRegistry reject invalid and duplicate registrations. Owner aliases are installed in Laravel's morph map and persisted in termable_type, so refactoring a PHP namespace does not corrupt attachment identity. Open vocabularies allow term creation through authorized application flows; closed vocabularies require an existing registered term.
Register every concrete owner class that can receive terms. A base-class alias is not inherited by subclasses because Laravel's morph map identifies concrete classes exactly.
Create a translated term
use Nvl\Taxonomy\Actions\CreateTermAction; use Nvl\Taxonomy\Data\MutateTermPayload; $term = app(CreateTermAction::class)->execute(new MutateTermPayload( taxonomy: 'topics', slug: 'engineering', translations: [ 'en' => ['name' => 'Engineering'], 'bg' => ['name' => 'Инженерство'], ], parentId: null, position: 10, meta: ['color' => 'blue'], ));
Taxonomy, parent, slug, position, metadata, and owner attachments are structural. Name and description exist only in terms_i18n and resolve through nvl/translatable. Slugs are canonical and do not change with locale. UUID-shaped slugs are reserved for unambiguous identifier references.
Updates use UpdateTermAction and require expectedRevision. Stale writes fail instead of silently overwriting newer changes.
Hierarchy
Use MoveTermAction for reparenting. It rejects:
- parents from another vocabulary;
- a term as its own parent;
- ancestor/descendant cycles;
- hierarchy on a flat vocabulary;
- moves beyond the configured maximum depth;
- duplicate sibling slugs.
app(TaxonomyTree::class)->for($taxonomy, $locale) delegates tree ordering and translation loading to the package reader and returns Term identity/result handles. The tree's loaded child/translation relationships describe internal storage state and do not grant consumer traversal or serialization. Obtain owner term display projections through ListOwnerTaxonomyTermsContract, or an explicitly authorized host adapter for a custom tree projection; do not query/eager-load package models or recurse through their relations in an API transformer.
Attach terms
Use AttachTermsAction, DetachTermsAction, or SyncTermAttachmentsAction with a registered owner. Do not write the polymorphic attachment table directly. Vocabulary rules enforce allowed owner aliases and exclusive membership where configured.
Attachment actions serialize each owner/vocabulary set with Laravel atomic locks. Production nodes must use a shared lock-capable cache store.
MergeTermsAction moves attachments and eligible children under a connection-correct transaction and requires the expected revisions of both terms. DeleteTermAction requires an expected revision and a DeleteTermStrategy; it rejects unsafe deletion when attachments or children cannot be handled by that strategy.
Central translation management
Taxonomy registers its term resource with TranslationResourceRegistry for
central gathering, coverage, and reads using the package field whitelist, query
scope, authorization, and version hash. Terms declare
TranslationMutationPolicy::DomainActionOnly: generic central sync and locale
deletion actions reject term writes.
Write localized names and descriptions through CreateTermAction or
UpdateTermAction using the translations field of MutateTermPayload.
For updates, provide the expected term revision. Pass mode: TranslationSyncMode::Replace to
UpdateTermAction::execute() with the retained locale map to remove omitted
locales. This keeps localized changes inside Taxonomy's validation, revision,
and event workflow. Consumer applications must authorize these domain actions
before accepting user input.
Generate TypeScript declarations under Nvl.Taxonomy.*:
php artisan nvl:data:types:generate php artisan nvl:data:types:check
Configuration
Important groups are:
owners: stable aliases to Eloquent model classes;taxonomies: hierarchy, openness, exclusivity, depth, sorting, owner allowlist, and metadata validation;table_namesandstorage.connection;migrations.enabled;limits.metadata_bytes,metadata_depth,description_chars, andbulk_terms;transactions.attemptsfor deadlock retries;locks.secondsandlocks.wait_secondsfor attachment-set serialization;slugs.generatorandslugs.locale.
Metadata is bounded and validated; it is not an arbitrary query language.
Commands
php artisan nvl:taxonomy:doctor --strict --format=json php artisan nvl:taxonomy:rebuild --dry-run php artisan nvl:taxonomy:merge --help php artisan nvl:taxonomy:prune --dry-run php artisan nvl:taxonomy:prune category --include-closed --force
Doctor verifies required columns and unique indexes plus registry, connection, parent, attachment, translation, cycle, and depth invariants. Dry-run modes do not mutate state. Pruning protects closed vocabularies unless --include-closed is supplied explicitly.
Database and adoption
The schema indexes vocabulary/parent/slug, tree order, owner/type, term attachment, and locale lookups. Package migrations honor configured table names and connection where supported.
Tenant-local trees
Installing Taxonomy also installs the inert nvl/tenancy library. With tenancy
enabled, register every taxonomy owner as a canonical tenant resource and use
Nvl\Taxonomy\Models\Term as each configured vocabulary model. Run reviewed
adoption for the taxonomy package before tenant traffic; it copies split trees
and translations, rewrites owner attachments, and activates tenant-leading
parent and attachment constraints. Vocabulary aliases remain global immutable
configuration while every term, translation, and attachment is tenant-local.
Take a pre-cutover backup and treat the reviewed split/destination mapping as immutable. An interrupted run may resume after source or schema repair that is consistent with that mapping. Changed tenant or destination assignments require restore and a new reviewed prepare; dropping tenant columns is not rollback once duplicate slugs and copied trees exist. Cleanup traverses one tenant-owned graph at a time and must not prune another tenant's attachments.
Maintenance is explicit per tenant, for example:
php artisan nvl:taxonomy:rebuild category --tenant=<tenant-uuid> --dry-run php artisan nvl:taxonomy:prune tag --tenant=<tenant-uuid> --dry-run
Attachment Actions and ListOwnerTaxonomyTermsContract support the package's dedicated taxonomy storage boundary. Owner-to-term lazy/eager relations and inverse Term::entries() joins are package-internal storage behavior. Consumers use the bounded reader for display and the authorized C1 withAnyTerms, withAllTerms, withoutTerms, or inCategory host scopes with their explicit batch policy for filtering. Those correlated host scopes require the same physical database; raw relation traversal does not become supported merely because connections match.
For an existing schema, disable automatic migrations and run the doctor. Convert root sentinels to null, backfill dedicated translation rows, and resolve identifier differences in an application-owned reversible bridge. A table-name match is not schema compatibility.
Authorization, caching, and failures
This package ships no management routes. Applications authorize Action calls and any API built over them. Registry aliases and vocabulary rules are allowlists, not authorization by themselves.
TermChanged mutation events implement ShouldDispatchAfterCommit. Unknown vocabularies, invalid metadata, stale revisions, ambiguous slugs, hierarchy violations, and unsafe deletes are distinct failures.
Batched owner reads
Resolve Nvl\Taxonomy\Contracts\ListOwnerTaxonomyTermsContract for many-owner display reads. It reloads registered persisted owners by concrete class, keeps their global scopes and live-record guards, and admits one canonical connection and tenant context. Supply at most 100 input owners and 20 registered vocabulary names. Owner capability labels remain registration references; stored and returned identities use each owner's native Laravel morph class and string key. Declared vocabulary owner allowlists remain mandatory. The model's relationship configuration does not grant an additional capability.
use Nvl\Taxonomy\Contracts\ListOwnerTaxonomyTermsContract; $result = app(ListOwnerTaxonomyTermsContract::class)->execute( $articles, ['tag', 'category'], locale: 'bg', ); foreach ($articles as $article) { $terms = $result->owners->{$article->getMorphClass()}->{(string) $article->getKey()}; foreach ($terms->vocabularies->tag as $term) { $labels[] = $term->name; } }
The result supplies an object at both owner map levels, an object mapping requested vocabularies to explicit term lists, and separate first-request identity order. Numeric owner keys remain JSON object properties. Term DTOs contain only id, vocabulary, structural slug, nullable parentId, attachment position, localized name, and nullable description. Position comes from the attachment pivot. No models, relationships, tree paths, metadata, or lazy translation queries enter the public result. Localization uses the declared finite locale chain, including deterministic AnyAvailable fallback when configured, and preserves non-null empty copy.
The default Nvl\Taxonomy\Contracts\TaxonomyBatchAuthorization adapter preserves explicitly registered owner/vocabulary capability admission. Bind a host SQL adapter for private deployments; provider defaults use bindIf and preserve existing host bindings. Its four query-free methods authorize the loaded owner batch, constrain attachments, constrain terms, and independently express complete correlated host-owner/attachment/term visibility. Each SQL method receives its own nested AND group inside mandatory exact identities, registered vocabulary, live-record, connection, and tenant predicates. Imperative per-owner policies require an explicit SQL adapter; do not call single-owner actions or perform queries inside policy methods.
Attachment ranking transfers at most 101 facts per exact owner/vocabulary group and throws Nvl\Taxonomy\Exceptions\TaxonomyBatchReadException on visible overflow before loading term or translation payloads. The probe ceiling is 202,000 attachments and the successful payload ceiling is 200,000 term DTOs. Prefer fewer requested vocabularies for ordinary lists. Terms and translations load separately per vocabulary, with at most 10,000 term IDs in each vocabulary query, keeping PostgreSQL bindings bounded. Supported window engines are SQLite, PostgreSQL, MySQL, and MariaDB.
Measured total SQL counts stay fixed at 1, 25, and 100 owners on one concrete owner class, with localized populated vocabularies:
| Populated vocabularies | Disabled warm | Disabled cold | Enabled adopted Tenancy |
|---|---|---|---|
| One | 4 | 5 | 4 |
| Two | 6 | 7 | 6 |
The disabled measurement loads the inert Tenancy library; cold includes its first installation-state probe. Every additional concrete owner class adds one canonical reload query. Two owner classes measured 5/7 warm queries for one/two populated vocabularies; registered custom term models retain the same two loads per populated vocabulary. An empty owner input performs zero SQL. Empty admitted vocabularies skip their term/translation payload loads. Category traversal has a separate bounded hierarchy budget.
The existing withAnyTerms, withAllTerms, withoutTerms, and inCategory host scopes accept an optional explicit batch policy. They retain selections and unrelated predicates, reject altered builder connections/FROM clauses/unions, and restore registered tenant and native or custom soft-delete guards even when callers remove global scopes. Any with no references matches nothing; All and Without with no references preserve the admitted host set. All checks each reference independently, so a UUID and slug may identify the same attached term. Category filters reload the root through its registered scoped term model and policy, then traverse only visible child sets bounded by the configured bulk-term limit; overflow fails explicitly instead of loading the whole vocabulary.
Host filters require the owner table identifier to differ from both the requested vocabulary's registered term-model table and the canonical attachment table. They conservatively reject matching table basenames without case sensitivity, including schema-qualified and configured physical names and same-named tables in distinct schemas. Nonempty Any/All/Without and every Category filter throw Nvl\Taxonomy\Exceptions\TaxonomyBatchReadException before storage SQL for those unsupported correlations. Empty-reference scopes keep their admitted host semantics because they need no term correlation. Use Nvl\Taxonomy\Contracts\ListOwnerTaxonomyTermsContract for bounded display reads of overlapping owners; its captured native identity pairs remain supported.
Verification
The package tests cover UUID identifiers, stable morph aliases, translation fallback, slug stability, tree order, cycles, subtree depth, moves, merges, attachments, exclusivity, deletion policies, maintenance safety, and configured-connection behavior. The local Dagger gate runs PHP 8.4/Laravel 13 suites and MySQL/PostgreSQL persistence contracts. Other declared platforms need separate compatibility evidence.
See UPGRADING.md, SECURITY.md, CONTRIBUTING.md, and CHANGELOG.md.
Injectable workflow contracts
Constructor-inject focused interfaces from Nvl\Taxonomy\Contracts when composing host workflows. Each interface retains the native Action’s complete execute parameters, defaults, return type, and documented generic/shape result. Concrete Actions remain directly usable in major 5.
use Nvl\Taxonomy\Contracts\CreateTermContract; use Nvl\Taxonomy\Data\MutateTermPayload; use Nvl\Taxonomy\Models\Term; final readonly class CreateTermWorkflow { public function __construct(private CreateTermContract $workflow) {} public function execute(MutateTermPayload $data): Term { return $this->workflow->execute($data); } }
The provider installs conditional transient defaults (bindIf) for the following selected workflows. A host interface binding registered before package discovery is retained; a later binding/instance replacement is used by newly resolved host services. Keep authorization, validation, query ownership, and mutation behavior inside the owning package workflow.
| Contract | Native implementation |
|---|---|
AttachTermsContract |
AttachTermsAction |
CreateTermContract |
CreateTermAction |
DeleteTermContract |
DeleteTermAction |
DetachTermsContract |
DetachTermsAction |
ListOwnerTaxonomyTermsContract |
ListOwnerTaxonomyTermsAction |
MergeTermsContract |
MergeTermsAction |
MoveTermContract |
MoveTermAction |
SyncTermAttachmentsContract |
SyncTermAttachmentsAction |
UpdateTermContract |
UpdateTermAction |
TaxonomyTreeContract::for(string $taxonomy, ?string $locale = null) returns Illuminate\Database\Eloquent\Collection<int, Nvl\Taxonomy\Models\Term>. TermResolverContract::resolve(string $taxonomy, array $references, bool $createMissing = true) accepts list<Term|string> and returns list<Term>. Inject these transient contracts for native localized tree/reference workflows. Resolution may create permitted open-vocabulary roots when createMissing is true. Registered Term model handles remain native package identities.
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.
The current readable handle fields are Term: id, taxonomy, slug, position, revision, created_at, updated_at. All other package model handles have no readable attribute grant.
Shared owner identity
Declare a model once in config/nvl-core.php:
'owners' => [Article::class],
Enable this package capability separately in config/nvl-taxonomy.php:
'owners' => [Article::class],
The shared alias must match the Taxonomy owner identity. Registering an identity does not expand vocabulary allowed_owners or authorize term mutations. 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.
Shared infrastructure options
Attachment actions and maintenance command locks use nvl-taxonomy.locks.store, then nvl-core.locks.store, then cache.default. locks.seconds and locks.wait_seconds still control attachment locking, and command lock durations remain unchanged. Production nodes must share a lock-capable store.
Next major: isolated schema identities
Use nvl-taxonomy.tables.<logical-key> for every table and nvl-taxonomy.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 |
|---|---|---|
terms |
nvl_taxonomy_terms |
terms |
i18n |
nvl_taxonomy_i18n |
terms_i18n |
termables |
nvl_taxonomy_termables |
termables |
tenant_adoption_copies |
nvl_taxonomy_tenant_adoption_copies |
term_tenant_adoption_copies |
Migration filenames contain nvl_taxonomy_. 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>:…. Attachment locks use nvl:taxonomy:attachments: instead of the generic attachments: key. Rebuild and prune locks use nvl:taxonomy:rebuild: and nvl:taxonomy:prune: outside tenant identities. Existing generic host locks are never acquired or removed. See UPGRADING for coordinated worker and lock lease cutover.
Canonical configuration ownership
Use nvl-taxonomy settings in config/nvl-taxonomy.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\Taxonomy\Contracts\TaxonomyTreeContract in Laravel's native container for a host-workflow test:
use Nvl\Taxonomy\Contracts\TaxonomyTreeContract; $double = Mockery::mock(TaxonomyTreeContract::class); $this->app->instance(TaxonomyTreeContract::class, $double); // Configure the exact for 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\Taxonomy\Models\Term; $fixture = Term::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 |
|---|---|
TermFactory |
category(), tag() |
TermTranslationFactory |
forTerm(Term $parent) |
TermableFactory |
forTerm(Term $parent), forOwner(Model $owner) |
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 |
|---|---|---|---|
invalid_term_operation |
422 | Declared safe scalar/array map; otherwise {} |
nvl-taxonomy::responsecode.invalid_term_operation |
term_not_found |
404 | Declared safe scalar/array map; otherwise {} |
nvl-taxonomy::responsecode.term_not_found |
operation_failed |
Exception-defined; see suggestedStatus() |
Declared safe scalar/array map; otherwise {} |
nvl-taxonomy::responsecode.operation_failed |
unsafe_term_deletion |
409 | Declared safe scalar/array map; otherwise {} |
nvl-taxonomy::responsecode.unsafe_term_deletion |
flat_vocabulary |
422 | Declared safe scalar/array map; otherwise {} |
nvl-taxonomy::responsecode.flat_vocabulary |
ambiguous_term_reference |
422 | Declared safe scalar/array map; otherwise {} |
nvl-taxonomy::responsecode.ambiguous_term_reference |
closed_vocabulary |
422 | Declared safe scalar/array map; otherwise {} |
nvl-taxonomy::responsecode.closed_vocabulary |
circular_hierarchy |
409 | Declared safe scalar/array map; otherwise {} |
nvl-taxonomy::responsecode.circular_hierarchy |
stale_term_version |
409 | Declared safe scalar/array map; otherwise {} |
nvl-taxonomy::responsecode.stale_term_version |
unknown_taxonomy |
404 | Declared safe scalar/array map; otherwise {} |
nvl-taxonomy::responsecode.unknown_taxonomy |
invalid_parent |
422 | Declared safe scalar/array map; otherwise {} |
nvl-taxonomy::responsecode.invalid_parent |
batch_read_unavailable |
500 | Declared safe scalar/array map; otherwise {} |
nvl-taxonomy::responsecode.batch_read_unavailable |
maximum_depth_exceeded |
422 | Declared safe scalar/array map; otherwise {} |
nvl-taxonomy::responsecode.maximum_depth_exceeded |
duplicate_sibling_slug |
409 | Declared safe scalar/array map; otherwise {} |
nvl-taxonomy::responsecode.duplicate_sibling_slug |
License
Released under the MIT License.
Owner capability relations metafields(), comments(), nvlMediaAssociations() and termables() are enforced statically, not at runtime. They remain ordinary Eloquent relations for package internals. Enable vendor/nvl/core/support/consumer-audit.neon in the host PHPStan configuration, and use public workflow contracts and batch readers in consumer code. The static rules do not replace runtime authorization.
Optional ownership and the configured Term root
Set nvl-taxonomy.models.term to a concrete Term subclass. Its
ownershipDefinition() can return OwnershipDefinition::column(Company::class, 'company_id') or OwnershipDefinition::pivot(Company::class, 'term_owners', 'term_id', 'company_id'). The host supplies that native column/FK or exclusive
primary pivot and installs the optional nvl/ownership runtime. Base models
remain unscoped by default.
The configured root is canonical across vocabularies; vocabulary definitions
retain hierarchy, depth, ordering, openness and allowed host rules. Their older
model conveniences remain available with the default unscoped root. Consumer
hooks, casts, factories, relationships and framework bindings use the configured
root. Alternate base models cannot access adopted storage.
Declare canonical host resource keys in nvl-taxonomy.ownership.owner_resources.
Attachments inherit their actual host, locale rows inherit their term, and both
attachment terms and tree parents must share the native owner. Use attachment,
move, merge and delete Actions; direct relationship pivot writes are rejected.
Bulk attachment mutations hold the actual host source transaction through commit.
Maintenance commands run inside the host's admitted OwnershipRunner scope;
--tenant remains only for historical Tenancy installations.
Existing installations use the reviewed taxonomy ownership adoption adapter
with an explicit mapping for every retained term, locale and attachment. It
preserves UUIDs and legacy provenance, restores canonical FKs, verifies tree and
attachment ownership, and replaces global sibling uniqueness with owner-local
uniqueness before activation. A failed verification never opens the graph.
Disabling or removing the enforcing runtime cannot expose adopted rows.