Skip to content
Guide

Data Model and Migrations

Add a category field to Note from migration through API, form and list.

Data Model and Migrations

Add an optional category to the Note created in Create a Resource. Existing Notes keep null; users can enter up to 80 characters. All file paths are App-relative. Apply migrations only to a practice tenant until the upgrade has been reviewed.

Add the column

Create database/migrations/tenant/2026_09_07_120000_add_category_to_wsp_notes_table.php with a unique timestamp later than the create-table migration:

Code example
PHP
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('wsp_notes', function (Blueprint $table): void {
            $table->string('category', 80)->nullable();
        });
    }

    public function down(): void
    {
        Schema::table('wsp_notes', function (Blueprint $table): void {
            $table->dropColumn('category');
        });
    }
};

Do not edit an already deployed create-table migration. This forward migration preserves existing rows; its down() drops category data. The generated Manifest already returns this App's tenant migration directory using __DIR__, so no host registration is needed.

Accept and return the value

In src/Models/Note.php, append 'category' to $fillable. No cast is needed for a nullable string. In both store() and update() validation arrays in src/Http/Controllers/NoteController.php, add:

Code example
PHP
'category' => ['sometimes', 'nullable', 'string', 'max:80'],

The generated serialize() starts with $record->toArray(), so this ordinary attribute is already returned by list, show and write responses. Check $hidden/$visible if you have customized serialization; never make a protected field public by following this recipe.

In resources/js/resources/notes/note-resource-contract.ts, add category: string | null; to NoteRecord. The following JSON is the new part of an authorized request and response, not a complete create request:

Code example
JSON
{ "category": "Planning" }

A create request still needs name and the explicit legal_entity_public_id. Omitting category preserves its value during update; sending null clears it.

Connect form state and display

In section/NoteFormSection.tsx under resources/js/resources/notes/, extend the onSubmit payload type to { name: string; category: string | null }. Add the following state beside the generated name state, replacing the existing useMarkTabDirty(name !== initialName) call:

Code example
TSX
const initialCategory = initialItem?.category ?? '';
const [category, setCategory] = useState(initialCategory);

useEffect(() => {
    setCategory(initialCategory);
}, [initialCategory]);

useMarkTabDirty(name !== initialName || category !== initialCategory);

Change the submit call to onSubmit({ name, category: category.trim() || null }); and add category, setCategory to the NxResourceInformationForm context. In surface/NoteFormSurface.tsx, extend mutationFn's payload type to the same { name: string; category: string | null }. The existing api.put(url, payload) and spread into api.post() forward it; keep target selection, authorization, error parsing and completion handling.

In note-information-schema.tsx, add category: string; and setCategory: (category: string) => void; to NoteInformationEditContext, then insert this field after name:

Code example
TSX
{
    key: 'category',
    labelKey: 'workshop.note.category.label',
    view: ({ item }) => resourceInformationValue(item.category),
    edit: {
        kind: 'control',
        error: ({ context }) => firstFieldError(context.fieldErrors, 'category'),
        render: ({ context }, slot) => (
            <NxTextInput
                id={slot.id}
                aria-describedby={slot.describedBy}
                invalid={slot.invalid}
                value={context.category}
                maxLength={80}
                onChange={(event) => {
                    context.setCategory(event.target.value);
                    context.onFieldChange?.('category');
                }}
            />
        ),
    },
},

The schema's imports already supply these primitives. Its view projection feeds the generated Show section and Inspector; its edit projection feeds the form. The slot attributes preserve label and error associations.

Display it in the list

Add this column to the array in section/NoteListSection.tsx. Its existing imports provide ResourceTextCell:

Code example
TSX
{
    key: 'category',
    header: t('workshop.note.category.label'),
    size: 'meta',
    cell: (row) => (
        <ResourceTextCell primary={row.category ?? t('common.cell.empty')} />
    ),
},

To make category searchable and sortable as well, add ->field('category', searchable: true, sortable: true) to Note::resourceListFields(). The backend remains the list-query allowlist; do not create a second frontend allowlist.

Add workshop.note.category.label to each catalog in resources/lang/: Category in en.json, 분류 in ko.json, and 分类 in zh.json. See Localize an App for cache refresh and validation.

Publishing category to external consumers or the Dashboard builder is a separate choice. If needed, declare its nullable string field in NoteModule's descriptor and review that public contract version. A table column alone is not a public descriptor declaration.

Verify the field

From the Core root, set the practice tenant ID and run:

Code example
Shell
nexia_tenant=REPLACE_WITH_TENANT_ID
task db:tenant:migrate TENANT="$nexia_tenant"

Create a Note with category, edit it, clear the value, and reopen list/detail/Inspector. Verify an existing Note still opens. Send a category over 80 characters directly to the API and expect HTTP 422 with a category field error; the input’s maxLength prevents that case through ordinary typing. In Test an App, cover persistence and the rejected input through the API.

The baseline table contains a UUID, name, status, timestamps, soft deletion, ownership columns and indexes. It does not automatically supply your business uniqueness, audit evidence or concurrency protocol. Add constraints for actual invariants and use a transaction or explicit concurrency check for conflicting writes. For another App's record use Resource References, not a foreign key into its tables.

Source of truth: docs/developers/content/en/building-apps/data-model-and-migrations.md