Skip to content
Reference

Resource contracts

The exact generator signature, Resource Module contract, generated output, and validation errors for an App-owned Resource.

Resource contracts

Dashboard composition fields

publicForBuilder only makes a Resource eligible for catalog review. It does not make every descriptor field visible or authorize rows. Publish composition.selectable, groupable, aggregate operations, and time buckets only for direct fields the existing read path may expose. For a custom scope, redaction, or projection, implement CompositionQueryContribution; its provider returns an AuthorizedCompositionQuery with the restored actor and exact organization targets. Core uses only its published aliases. Reuse the Resource read permission and predicate; never add a Dashboard permission or accept SQL from a Dashboard or Agent client.

Use Create a Resource for the generation workflow. This reference explains the command options and the generated module contract: stable identity, model, permissions, navigation, and query fields. A discoverable module is not proof of authorized runtime access; preserve the authorization invariants below.

For monetary values, publish required_dimensions: ['currency'] beside aggregations and unit in the field's composition metadata or provider fields. The named same-source field must be groupable. Core allows value aggregates only when that field is an unbucketed dimension or has a conjunctive scalar eq constraint, globally or on the measure. OR/NOT/IN do not qualify. Derived measures and comparison queries retain the requirement; count and distinct-count are exempt. This does not convert currencies.

Favorite target metadata

Core can emit favorite_target metadata for a standard Resource only after it has the catalog model, its declared .read permission, a standard stored-owner authorization contract, a Shell Resource show shape, and a declared destination. The metadata is a server-verified record identity, not a route or presentation snapshot. Use it with FavoriteButton; do not reconstruct it from a list URL or model class.

An App does not persist or send a title or URL as favorite identity. Core re-resolves the identity under the current actor whenever it reads or changes a favorite. An owner summary may still provide the current authorized title and route as presentation after that resolution. Standard App Resources use the same exact-record authority path as Core Resources. A custom App Resource can use its existing authorization-safe ResourceSummaryContribution when it supplies the current display and a canonical reentry route. For example, the People Worker summary resolves a favorite without a page organization selection through the Worker visibility scope and record policy; an explicit organization selection still uses its narrower relationship rule.

ResourceRef canonical identity metadata identifies an App and fully-qualified resource key for Core's candidate classification. It does not authorize a record or create a favorite. Unknown keys, unsupported discriminators, missing routes, and records that fail the current authorization check resolve to null. Do not point a read favorite at an edit route just to make it open.

A list response that is eligible for this capability may expose meta.list_schema.resource_key and the favorites.only filter. The key is the same canonical record key used by FavoriteTarget; the host uses it to refresh an active Favorites-only list after a mutation. Apps forward the list schema unchanged and do not derive a cache key from a route. The published filter has the ordinary list option { value: "1", label: "favorites.only" }; do not add a favorite-only contribution, endpoint, or parallel pagination contract.

When a primary cell links to a related Resource but represents its row's owner, pass the owner identity as favoriteCandidate. The host first authorizes that candidate through its status endpoint before it renders a button. Set favoriteable={false} on a related-record cell that must not receive a star. Use favoriteTarget only when the response already carries the authorized favorite_target metadata; it does not make an App authorize a target.

Signature

The generator that produces an App Resource:

nexia-apps:make-package-resource
    {package}                        Existing package app key or display name (e.g., quality-inspection)
    {name}                           Resource name in PascalCase (e.g., WorkOrder)
    --icon=box                       Shell icon name for the app-local resource navigation entry
    --sort=                          App-local navigation sort order; defaults to the next slot
    --navigation-group=operations    insights | management | operations | master-data | settings
    --navigation-subgroup=           Optional App-owned subgroup id
    --record-owner=legal_entity      Canonical record owner (tenant | legal_entity)
    --label-ko=                      Authored Korean singular label
    --label-ko-plural=               Authored Korean plural/collection label
    --label-zh=                      Authored Chinese singular label
    --label-zh-plural=               Authored Chinese plural/collection label
    --without-navigation             Routable and cataloged, but no App Menu entry
    --dry-run                        Report files and package-local edits without writing
    --force                          Rewrite generated resource files in place

The generated Resource Module extends the SDK descriptor bridge and implements five catalog interfaces:

Code example
PHP
use Nexia\AppRuntime\Contracts\ShellResourceContribution;
use Nexia\Contribution\AbstractResourceModule;              // + appDescriptors() set
use Nexia\Contribution\Contracts\ResourceAuthorizationContribution;   // + resourceAuthorization()
use Nexia\Contribution\Contracts\ResourceCatalogContribution;         // + resourceKey(), resourceModelClass()
use Nexia\Navigation\Contracts\NavigationContribution;                // + navigationItems()
use Nexia\Permission\Contracts\PermissionContribution;                // + catalogPermissionDefinitions()

Three of those methods are supplied by SDK traits rather than written by hand. ContributesResourcePermissions implements catalogPermissionDefinitions() from your permissionResources() array; ContributesNavigationDestination implements navigationItems() from your $navigation array; HasShellResource supplies the Shell resource facet. AbstractResourceModule::appDescriptors() publishes the optional ResourceDescriptor returned by resourceModuleDefinition() through the single AppDescriptorContribution contract. ResourceDescriptorContract validates Resource lifecycle-event facets; it does not revive the removed per-type descriptor contribution interface.

Minimal example

This standalone example uses the Note model and NoteStatus enum from Create a Resource. It shows the required interfaces without the generator's optional Agent navigation action. Keep the generated module when following the tutorial; do not register a second owner for workshop.note.

Code example
PHP
<?php

declare(strict_types=1);

namespace Amuzcorp\Nexia\Workshop\Contribution\Resources;

use Amuzcorp\Nexia\Workshop\Models\Note;
use Amuzcorp\Nexia\Workshop\Enums\NoteStatus;
use Nexia\AppDescriptors\DescriptorStatus;
use Nexia\AppDescriptors\ResourceDescriptor;
use Nexia\AppRuntime\Concerns\HasShellResource;
use Nexia\AppRuntime\Contracts\ShellResourceContribution;
use Nexia\Contribution\AbstractResourceModule;
use Nexia\Contribution\ResourceAuthorizationContract;
use Nexia\Contribution\Contracts\ResourceAuthorizationContribution;
use Nexia\Contribution\Contracts\ResourceCatalogContribution;
use Nexia\Contribution\ResourceLegalEntityParticipation;
use Nexia\Contribution\ResourceModuleDefinition;
use Nexia\Contribution\ResourceRecordOwner;
use Nexia\Navigation\Concerns\ContributesNavigationDestination;
use Nexia\Navigation\Contracts\NavigationContribution;
use Nexia\Permission\AssignmentScope;
use Nexia\Permission\Concerns\ContributesResourcePermissions;
use Nexia\Permission\Contracts\PermissionContribution;

final class NoteModule extends AbstractResourceModule implements NavigationContribution, PermissionContribution, ResourceAuthorizationContribution, ResourceCatalogContribution, ShellResourceContribution
{
    use ContributesNavigationDestination;
    use ContributesResourcePermissions;
    use HasShellResource;

    protected static array $navigation = [
        'path' => 'notes',
        'icon' => 'box',
        'order' => 50,
        'context' => 'app',
        'group' => 'operations',
        'visible' => true,
    ];

    public static function resourceKey(): string
    {
        return 'workshop.note';
    }

    public static function resourceModelClass(): string
    {
        return Note::class;
    }

    public static function resourceModuleDefinition(): ResourceModuleDefinition
    {
        return ResourceModuleDefinition::fromDescriptor(new ResourceDescriptor(
            key: self::resourceKey(),
            version: '1.0',
            status: DescriptorStatus::Active,
            labelKey: 'workshop.note.list.title',
            fieldSchema: [
                'public_id' => ['type' => 'string'],
                'name' => ['type' => 'string'],
                'status' => [
                    'type' => 'string',
                    'enum' => NoteStatus::values(),
                    'enum_labels' => NoteStatus::labelKeys(),
                ],
            ],
            searchSchema: ['label_fields' => ['name']],
        ));
    }

    public static function permissionAssignmentScope(): AssignmentScope
    {
        return AssignmentScope::LegalEntity;
    }

    public static function resourceAuthorization(): ResourceAuthorizationContract
    {
        return ResourceAuthorizationContract::standard(
            ResourceRecordOwner::LegalEntity,
            AssignmentScope::LegalEntity,
            ResourceLegalEntityParticipation::RecordOwner,
        );
    }

    public static function permissionResources(): array
    {
        return [
            'workshop.note' => ['read', 'create', 'update', 'delete'],
        ];
    }
}

ResourceModuleDefinition is also the canonical naming boundary. A public descriptor supplies its labelKey automatically. A catalog-only Resource can remain descriptor-less while still declaring the same screen-independent name:

Code example
PHP
return ResourceModuleDefinition::withoutDescriptor(
    self::resourceKey(),
    'workshop.note.resource.label',
);

List titles, navigation copy, and page descriptions may stay contextual. Reuse the module label wherever a surface needs the Resource name itself instead of adding a surface-specific alias with the same text.

Parameters

ParameterTypeRequiredDefaultBehavior
packagestringYes—Existing package app key or display name. Resolved against packages/{app_key}/composer.json; app_key and app_table_prefix are read from extra.nexia.app and never re-derived.
namestringYes—PascalCase Resource name. Seeds the class names, the snake-case Resource Key suffix, the plural table name, and the English labels.

Options

OptionDefaultEffect
--record-ownerlegal_entitytenant or legal_entity. Determines table shape, API route prefix, permission middleware, Policy scope check, list predicate, and frontend request context. No other value is accepted.
--navigation-groupoperationsOne of insights, management, operations, master-data, settings. The Shell owns these five group labels.
--navigation-subgroupnoneApp-owned subgroup id. Requires a valid group. Omit to keep the destination flat within its group.
--iconboxShell icon name for the App Menu entry. Unknown names fall back to a neutral icon.
--sortnext slotApp Menu order; lower appears earlier. The first Resource gets 10. With --force, an existing order is preserved unless --sort is passed explicitly.
--without-navigationoffWrites visible => false. Routes, permissions, catalog metadata, Shell resource routes, and Filament registration all remain; only the App Menu entry is suppressed.
--label-ko, --label-ko-plural—Korean singular is required. The collection label defaults to the singular label.
--label-zh, --label-zh-pluralEnglish labelsOptional authored Chinese labels. The collection label defaults to the Chinese singular; omitting both records the derived English labels as explicit fallback values.
--dry-runoffReports the file and edit plan; writes nothing.
--forceoffRewrites generated Resource files. Package-local manifest, route, and frontend-entry edits stay idempotent. Does not permit a record-owner change.

English labels derive from name. A Korean singular label is required unless an exact automation default is configured, and the Korean collection label defaults to it. Chinese labels are optional and use the derived English labels as explicit fallbacks when omitted. A Korean value equal to its English counterpart is rejected unless it appears in nexia.translation_catalog.validation.identical_value_allowlist.

Output

For nexia-apps:make-package-resource workshop Note --record-owner=legal_entity --label-ko=노트 --label-ko-plural=노트, all paths relative to packages/workshop/:

src/Enums/NoteStatus.php
src/Models/Note.php
src/Policies/NotePolicy.php
src/Http/Controllers/NoteController.php
src/Contribution/Resources/NoteModule.php
database/migrations/tenant/{timestamp}_create_wsp_notes_table.php
src/Filament/Tenant/Resources/Notes/NoteResource.php
src/Filament/Tenant/Resources/Notes/Pages/{ListNotes,ViewNote}.php
resources/js/resources/notes/note-resource-contract.ts
resources/js/resources/notes/note-information-schema.tsx
resources/js/resources/notes/surface/Note{List,Show,Form}Surface.tsx
resources/js/resources/notes/section/Note{List,Show,Form}Section.tsx
resources/js/resources/notes/inspector/{NoteInspector.tsx,register-note-inspector.ts}
tests/Feature/NoteAuthorizationTest.php

Twenty files. The generated Show and Form sections both project note-information-schema.tsx, so field order, labels, requirements, spans, and deliberate View/Create/Edit omissions have one source. The generated test asserts the Module's declared ownership and scope — it makes no HTTP request, so endpoint authorization tests are yours to add.

Two existing files are edited; the entry’s glob registration discovers the new Inspector without an index.ts import. Locale keys are merged into resources/lang/{en,ko,zh}.json:

FileEdit
src/{App}AppManifest.phpRegisters the tenant Filament Resource only
routes/routes.phpController import and canonical CRUD routes, plus explicit-target compatibility routes for Legal Entity ownership

Nothing outside packages/{app_key}/ is written. Host routes/api/, host permission providers, host Shell files, host i18n bootstrap, and host frontend aliases are never edited.

Generated API routes

Record ownerPrefixMiddleware pattern
tenant/api/{app_key}/{resources}can.tenant_wide:{resource_key}.{action}
legal_entity/api/{app_key}/{resources}Controller resolves requested list/create targets or the stored record owner, then authorizes the operation
legal_entity (compatibility)/api/legal-entities/{legalEntity:public_id}/{app_key}/{resources}can.scope:{resource_key}.{action},core.legal_entity,legalEntity

Five CRUD routes under each prefix:

GET    {base}                      read
GET    {base}/{parameter}          read
POST   {base}                      create
PUT    {base}/{parameter}          update
DELETE {base}/{parameter}          delete

A command route such as POST {member}/retire is not generated. Add it, and its action, when your Resource needs one.

Member routes constrain the parameter with whereUuid and resolve public_id. All routes additionally carry app.installed:{app_key}, which fails closed unless the App and its transitive prerequisites are code-loaded, active, and successfully initialized for the current tenant.

Table and naming contract

ElementRuleExample
Table name{app_table_prefix}_{plural}wsp_notes
Foreign keysSemantic, unprefixedparent_note_id
Resource Key{app_key}.{resource_snake}workshop.note
Translation keys{resource_key}.*workshop.note.status.active
Frontend registry keyApp-prefixed PascalCaseWorkshopNoteListSurface
Public identifierpublic_id UUID, uniquepublic_id

The generated {Resource}Status enum is the single source for the closed value set. The model casts status to it, the list filter derives options from filterOptions(), the controller validates with Rule::enum(), and the descriptor derives fieldSchema enum from values() and enum_labels from labelKeys(). Declaring a status once on the enum keeps all four in sync.

Resource list field catalog

The model declares backend list-query capabilities through one ResourceListFields catalog. Declare a field key once, then attach any search, sort, and filter capabilities it supports:

Code example
PHP
use Amuzcorp\Nexia\Workshop\Enums\NoteStatus;
use Nexia\Laravel\Filters\Types\Exact;
use Nexia\Laravel\Resources\ResourceListFields;

public function resourceListFields(): ResourceListFields
{
    return ResourceListFields::make()
        ->field(
            'status',
            sortable: true,
            filter: new Exact('status'),
            label: 'workshop.note.status.label',
            options: NoteStatus::filterOptions(),
        )
        ->field('name', searchable: true, sortable: true)
        ->field('updated_at', sortable: true);
}

Filterable derives request validation, query application, the Scout search projection, and meta.list_schema from this catalog. A derived or aliased sort uses a Sort instance instead of true; lazy filter options may be supplied as a closure and are evaluated only when the frontend schema is built. Duplicate field keys are rejected.

This is not the public ResourceDescriptor::$fieldSchema: the descriptor tells other platform capabilities what the Resource exposes, while resourceListFields() is the allowlist for collection-query input. Table rendering and column visibility remain frontend concerns because a visual column need not map one-to-one to a query field.

Manual Resource Composition catalog

The host derives the manual Dashboard composition catalog from the Resource descriptor and existing authorization contract. A standard Resource publishes direct descriptor fields; a custom-scope, redacted, or projected Resource may bind one CompositionQueryContribution. Do not add a reporting table or a second graph contract.

A Resource is eligible only while all of these remain true:

  • its descriptor is active, has a translated labelKey, and keeps publicForBuilder: true;
  • the descriptor and Resource catalog have the same owning App, and that App is operational for the tenant;
  • the Resource declares supported standard SQL authorization, or binds an authorized composition provider for its custom visibility profile; and
  • its field schema includes direct public_id identity and only fields it explicitly marks safe for composition.

The public catalog strips models, tables, columns, permission slugs, and other physical bindings. It includes the public_id identity and only direct descriptor fields whose composition metadata grants the requested capability: selectable, groupable, aggregation operations, or time buckets. A usable output field needs a translated label_key; enum choices use enum_labels. resourceListFields() contributes only supported Exact eq/in filters and does not automatically return a field value. Unsafe, derived, sensitive, actor, hash, token, and internal _id fields remain excluded. Set publicForBuilder: false when the Resource should never appear in the builder.

An authorized provider receives the restored actor, request, Resource key, and exact Legal Entity/Operating Unit target pairs. It returns its already-scoped Eloquent builder plus the aliases and capabilities Core may use. Core wraps that query and never accepts client SQL, a physical column, or an undeclared alias. The provider reuses the Resource's read permission and row predicate; it does not create a Dashboard permission.

Relationships are also derived rather than declared as fixed App pairs. A direct resource_reference can become a row relationship only when its descriptor, physical field, accepted target, and the target's unique public_id all agree. Two Resources can become an aggregate relationship over Party only when each declares party_public_id for directory.party and Core can prove the matching party_id foreign-key topology. The browser receives only opaque relationship keys and semantic endpoints.

An Agent call without search or resource_keys receives a complete compact authorized Resource index, without field or relationship details. It must make a focused follow-up for those details; the manual Builder retains its complete unfiltered catalog response.

The app-neutral CompositionSpec wire has one current schema, schema_version: 1. It carries a bounded list of source aliases and opaque relationships; every relationship identifies its source, target, and explicit match behavior. population is required for aggregates, and row results also require row_source. The SDK validates the wire and declared aliases; Core alone resolves keys, proves graph semantics and costs, and enforces the live catalog limits: eight sources, seven relationships/hops, 100 row results, 500 aggregate buckets, and a 3,000 ms SQL timeout. Apps do not publish a second composition graph contract. For the App decision path, including optional reporting metadata and the boundary between Report, Dashboard, and Agent use, read Governed Reports and Resource Composition.

Committed mutation identity

resourceKey() and resourceModelClass() also enroll the Resource in the host's committed-mutation observer. Ordinary Eloquent created, updated, deleted, and restored events publish the stable Resource Key, operation, and string/integer route key after commit. The generated controller and stub do not add save/delete event code, and the signal contains no model fields.

This automatic CRUD observation is not a public lifecycle declaration. Declare every ResourceLifecycleEventDescriptor and its payload schema manually in ResourceDescriptor::$lifecycleEvents; ResourceDescriptorContract validates that declared public contract. The observer only calls the generic MutationCollector::resourceChanged for a catalog-bound model after commit and never invents a lifecycle event or payload.

Query-builder bulk updates, raw SQL, and other writes that bypass model events are not observed automatically. Inject Nexia\Mutation\Contracts\MutationPublisher in the owning service and call resourceChanged(resourceKey, operation, optionalResourceId) after the write succeeds. If the business action has no Resource Catalog root, publish a stable owner-prefixed action key with actionSucceeded() instead.

These identities let an already-parked browser waiter decide whether to recheck authoritative state. They are not durable events and never prove that a Setup criterion or business invariant is satisfied. Do not instrument every controller, match routes, or treat every successful response as a Resource change.

Authorization invariants

ResourceAuthorizationContract rejects incoherent combinations in its constructor, so an invalid declaration fails at boot rather than at request time:

CombinationResult
Tenant + participation other than NoneInvalidArgumentException: a tenant-owned Resource cannot use direct Legal Entity-owner participation
LegalEntity + participation other than RecordOwnerInvalidArgumentException: a direct Legal Entity-owned Resource must identify its record owner as the Legal Entity participant

AssignmentScope::supportsGrantAt() accepts the same or a broader scope. A LegalEntity permission accepts tenant and Legal Entity Grants, but not Operating Unit Grants.

Use ResourceAuthorizationContract::custom() instead of standard() only when the visibility rule is not plain tenant or Legal Entity ownership — it selects ResourceVisibilityProfile::Custom and makes you responsible for the predicate.

Errors

MessageCauseResolution
Package app not found at {dir}. Run nexia-apps:make-package-app first.No package at the resolved pathCreate the App Package baseline first
Package routes must include [app.installed:{app_key}] before adding resources.Route group lacks the install guardAdd the middleware to the group in routes/routes.php
App manifest [{class}] must end with AppManifest.Manifest class name does not match the conventionRename the manifest class and its extra.nexia.app.manifest value
Unknown --record-owner value. Supported values: tenant, legal_entity.Any other value, including organizationPass tenant or legal_entity
A Korean resource label is required. Re-run with --label-ko=<label>.Required Korean singular label is missing in a non-interactive runSupply an authored Korean label; its collection label defaults to it and Chinese labels are optional
Localized resource label --label-ko must not copy the English singular label [X].Localized value equals the English valueProvide a real translation, or add the token to the identical-value allowlist
Invalid navigation placement. Use group insights, management, operations, master-data, or settings and an optional subgroup.Unknown group, or a subgroup without a valid groupUse one of the five Shell-owned groups
--icon must be a non-empty shell icon name.Empty --iconPass a name or omit the option
--sort must be an integer.Non-integer --sortPass an integer
Refusing to change record ownership from tenant to legal_entity through scaffold overwrite.--force with a different --record-owner than the existing declarationWrite and apply an explicit persistence/data migration, then update the Module declaration and enforcement paths together
Refusing to reinterpret an existing Resource that has no record-ownership declaration.Existing Module file has no ResourceRecordOwner:: declarationAdd the declaration explicitly before regenerating
CONFLICT {path} (anchor '{anchor}' not found exactly once)A package-local file the generator edits was hand-modified past recognitionRestore the anchor comment, or apply the edit manually

Use in your App

Generate your own Resource with Create a Resource, then inspect the generated module, model, policy, controller, migration, and authorization declaration test together. The Quality names above are illustrative; they are not paths to a maintained Resource implementation.

Resource operation metadata

ResourceDescriptor accepts optional actions (list<ResourceActionDescriptor>) and mutation (ResourceMutationDescriptor). They describe existing Resource APIs for generic platform consumers. Actions declare their exact route, permission, input schema, read/mutate effect, and optional label and business description. Mutation metadata separates create and update payloads. These descriptors do not register individual Agent tools or replace controller validation. See Agent Resource Data Runtime.

Source of truth: docs/developers/content/en/app-sdk/resource-contracts.md