Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Nova Notes Field Laravel Package

optimistdigital/nova-notes-field

Adds a notes field to Laravel Nova resources, letting users attach rich-text notes with @mentions and browse them in a timeline. Includes configurable mention suggestions, note creation, editing and deletion, plus a clean UI that fits naturally into Nova resource detail views.

View on GitHub
Deep Wiki
Context7

Getting Started

Prerequisites — confirm these before touching composer. A licensed Nova install (this package has zero value outside Nova), PHP 8.1+, Laravel 9–11. Verify against your matrix rather than the README:

composer show laravel/nova laravel/framework php | grep -E '^(name|versions)'
composer why-not outl1ne/nova-notes-field

The why-not output is the fastest way to discover that the package's constraint range does not include your Nova major. Do it first — it takes ten seconds and saves an afternoon.

Install, pinned. The package was published by Optimist Digital and rebranded to Outl1ne, so the composer name and PHP namespace may not agree on older tags. Check both the Packagist name and the use statement in the README against what actually lands in vendor/:

composer require outl1ne/nova-notes-field:1.0.3   # exact tag, not ^1.0

If the Packagist listing is still under optimistdigital/nova-notes-field, install that name and note the namespace it exposes. From day one, prefer a fork in a private VCS repo over a floating Packagist constraint — see Gotchas.

Publish and migrate. Notes live in the package's own table (polymorphic notable_type / notable_id), not in a column on your model. Nothing works until the migration has run:

php artisan vendor:publish --provider="Outl1ne\NovaNotesField\NotesFieldServiceProvider" --tag="migrations"
php artisan migrate

Read the published migration before running it — you want to see the column set (author, body, timestamps), whether indexes on notable_type/notable_id exist, and whether there is any cascade behaviour. This file is the whole data model; five minutes here answers most of the governance questions.

First use case. Pick one low-stakes operational resource — a Customer, Order, or Ticket — and add a single field to its fields() method:

use Outl1ne\NovaNotesField\NotesField;

public function fields(NovaRequest $request)
{
    return [
        // ... existing fields

        NotesField::make('Notes')
            ->onlyOnDetail()
            ->canSee(fn (NovaRequest $request) => $request->user()->can('addInternalNote', $this->resource)),
    ];
}

Then write one note through the UI, confirm the row in nova_notes, and confirm it reappears after a page refresh. That round trip is the whole smoke test — put it in CI later.

Where to look first in the source. Three files tell you everything: the field class (available setters — rows, placeholder, authorization helpers), the Vue component in resources/js (does it render the body with v-html?), and the service provider (routes, publish tags, config). It is a few hundred lines total; read all of it before you ship it.

Implementation Patterns

Detail-only is the default posture. Notes on an index view mean a per-row load of every note for every record on screen:

NotesField::make('Notes')->onlyOnDetail()

onlyOnDetail() also sidesteps the create-view problem (see Gotchas). If you genuinely need notes on the form view, use ->onlyOnForms() deliberately on a resource with low row counts, and never both plus index.

Two panels on one model. Because notes are keyed to the notable, a single field per resource is the natural shape. If you need separate streams — say "Support notes" vs "Billing notes" — check whether the field class exposes a key/discount or type setter to namespace them; if it does not, subclass the field and override the attribute or the component's endpoint payload rather than running two instances and hoping they don't collide.

Authorization belongs in policies, not in hideFromIndex(). Hiding the field is cosmetic. Writes go through the package's own endpoint, which may not run your resource policy at all. Verify by reading the route + controller: does it resolve the model through Nova's authorized lookup, or by raw notable_id? Then enforce your rule in a policy method and reference it from the field:

// CustomerPolicy
public function addInternalNote(User $user, Customer $customer): bool
{
    return $user->hasPermissionTo('customers.notes.write')
        && $user->tenant_id === $customer->tenant_id;
}

Apply the same method in the field's canSee, and — if the endpoint turns out to be unguarded — in a Gate::before or a small middleware registered against the package route. This is the single most important integration step and the one most likely to be skipped.

Lifecycle: decide what happens to notes when the parent dies. The notes table is polymorphic, so $customer->delete() leaves orphaned rows (soft deletes leave them attached and correct). Pick deliberately:

// option A: hard cleanup on force delete
protected static function booted(): void
{
    static::forceDeleting(fn ($customer) => NovaNote::where('notable_type', Customer::class)
        ->where('notable_id', $customer->id)
        ->delete());
}

or accept orphans and add a scheduled prune. Whichever you choose, write it down; "we'll notice" is not a retention policy.

Tenancy scoping. In a multi-tenant app, add a tenant_id to the published migration and scope reads in the field (or a global scope on the package's model, if it's swappable). Otherwise a misrouted request can surface another tenant's annotations — free text that, by definition, was written assuming privacy.

Testing. Add a feature test that posts a note and asserts the row exists. Silent save failures are the realistic failure mode here, so a green test on the write path is worth more than one on the read path:

public function test_note_is_persisted_for_a_customer(): void
{
    $user = User::factory()->create();
    $customer = Customer::factory()->create();

    $this->actingAs($user)
        ->postJson("/nova-vendor/nova-notes-field/notes", [
            'notable_type' => Customer::class,
            'notable_id' => $customer->id,
            'note' => 'Called re: renewal.',
        ])->assertSuccessful();

    $this->assertDatabaseHas('nova_notes', ['notable_id' => $customer->id]);
}

Confirm the actual route path and payload keys from the package source before writing the test — do not copy the above verbatim.

Reading notes outside the field. Because it is a normal table, DSAR exports, admin reports, and a future migration to a first-party implementation are all plain queries:

DB::table('nova_notes')
    ->where('notable_type', Customer::class)
    ->where('notable_id', $customer->id)
    ->orderByDesc('created_at')
    ->get();

Keep this query in your own code (a small repository or a Nova action), not scattered in Blade. It is your exit ramp, and it should be ready the day you need it.

Migration to a first-party field. The documented fallback is a Textarea plus a notes table with author and timestamp — roughly a day. Because notes already live in a separate table with a stable shape, the migration is mostly a notes → notes copy and a field swap. Design the pilot with that day budgeted, not as an emergency.

Gotchas and Tips

The package name and namespace may not match. Post-rebrand, optimistdigital/… and outl1ne/… can both resolve, with different namespaces. If use Outl1ne\NovaNotesField\NotesField; fails, grep -r "class NotesField" vendor/ and use the namespace you find. Do not assume the README matches the installed tag.

Nova 3 vs Nova 4/5 is a hard frontend break. Nova 3 fields are Vue 2 and use the FormField/DetailField mixins of that era; Nova 4/5 are Vue 3. The package ships compiled JS, so a stale dist/js/field.js will fail at runtime with a console error and a blank field, not a PHP exception. When you adopt, diff the compiled component's mixin imports against your Nova major and, if they mismatch, fork and rebuild (npm install && npm run prod in the package directory). Budget ~half a day per Nova major for this, forever.

Notes save independently of the resource form. The note is submitted to its own endpoint and persisted immediately — before (and regardless of) the "Update Resource" button. Consequences worth telling users about: a note survives an abandoned form edit, and a note saved while the resource has unsaved changes is attached to the old state. It also means validation errors on the main form do not roll back notes.

The create view has no model to attach to. On a resource's create form the parent doesn't exist yet, so a notes panel is either absent or writes to nothing. Use ->onlyOnDetail() or ->hideWhenCreating() and save yourself a support ticket.

Silent failure is the realistic failure mode. If the endpoint 404s (route not registered), the policy blocks it (403 swallowed by the UI), or the migration wasn't published, the user may see an empty panel rather than an error. When something's off, check in this order: nova_notes table exists → browser network tab on the save request → storage/logs/laravel.log. Add the CI write test so this cannot reach production unnoticed.

Confirm how the body is rendered. Free text rendered with v-html is stored XSS, and notes are written by staff who paste from email. Grep the Vue component for v-html. If it's there, either sanitize on write (a mutator stripping tags) or fork and change to {{ }}. Do not rely on Nova's "only admins write notes" as a mitigation — admin sessions are the highest-value XSS target you have.

Free text becomes PII, immediately and permanently. No retention controls, no redaction, no erasure path. Cap length (a maxlength on the component and a validator server-side if the endpoint accepts it), state a retention stance, and make sure your DSAR procedure includes a nova_notes query for the subject's records. Decide up front whether this is scratchpad or record-of-truth; if the latter, stop and build append-only.

Global search and index leakage. If notes are reachable via Nova's global search or a resource's searchable columns, an internal note about a customer can surface to someone who can see the customer but not the notes. Check searchable config and any Nova::search() registration, and confirm any path that reads notes re-applies your policy.

Pin, fork, or vendor — pick one on day one. ^1.0 from Packagist against a 52-star, single-maintainer, zero-dependent package means an upstream tag can break your deploy on a Friday. Either pin an exact version with a lock file you control, or (better) fork into a private VCS repo and install from a ref:

"repositories": [{"type": "vcs", "url": "[email protected]:yourorg/nova-notes-field.git"}],
"require": {"outl1ne/nova-notes-field": "dev-main#a1b2c3d"}

Extension points, in order of preference. (1) Subclass NotesField and override setters/component path — cheapest and upgrade-safe. (2) Publish and edit the Vue component in your own resources/js/fields and point the field at it via ->component(). (3) Patch the vendor file — never, unless it's a same-day hotfix you commit upstream.

Resist accretion. No mentions, notifications, attachments, threads, rich text, or cross-record search. Each is a separate validated need with a separate build. The moment you add one, you own a comment system instead of a textarea, and the cheap exit you bought with this package is gone.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin