outl1ne/nova-notes-field
A Laravel Nova field that adds a notes panel to your resources, letting users attach and manage timestamped notes directly from the detail view. Simple to install, configurable, and works with any Nova resource.
Install and publish.
composer require outl1ne/nova-notes-field
php artisan nova:publish
Then add the column it writes to. The package does not own its own table — it serialises a list of notes into one JSON column on your existing model, so this is your one migration:
Schema::table('customers', function (Blueprint $table) {
$table->json('notes')->nullable()->after('id');
});
Confirm the cast before you touch the field. The single most common first-run failure with a JSON-in-a-column Nova field is a double-encode/decode mismatch between the field's resolve()/fill() and your Eloquent cast. Open the field class and check:
cat vendor/outl1ne/nova-notes-field/composer.json | grep -A6 '"require"'
sed -n '1,120p' vendor/outl1ne/nova-notes-field/src/Notes.php
Look for three things in that file: the $component name, the fluent setters it exposes (placeholder, limit, read-only, hide-from-index), and whether it casts the value itself. If it does not, add protected $casts = ['notes' => 'array'] to the model — and if it does, don't, or you will store a stringified string and the timeline will render empty.
First use case: internal notes on a customer record.
use Outl1ne\NovaNotesField\Notes;
public function fields(NovaRequest $request): array
{
return [
ID::make()->sortable(),
Text::make('Name'),
Notes::make('Notes')
->onlyOnDetail(), // detail view first, always
];
}
Visiting one record, typing a note, and reloading tells you everything you need in thirty seconds: whether author/timestamp are captured server-side, whether it posts to its own endpoint or through the resource update form, and whether the value round-trips through your cast. That is the whole smoke test.
Detail-only first, widen deliberately. The default mental model for this field is context attached to a record, which belongs on the detail view. ->onlyOnDetail() keeps index tables clean and sidesteps the question of how a timeline renders in a 200-row table. Add ->hideFromIndex() if you're using ->showOnIndex() variants, and only reach for form mode once you understand that the package ships its own inline add-note form with its own request — putting it in a form and leaving the inline form active gives you two write paths to one column.
Group it in a panel that names its audience. The most likely real-world misuse is someone assuming notes reach the customer. One panel name prevents it:
Panel::make('Internal', [
Notes::make('Notes')->onlyOnDetail(),
Textarea::make('Internal Summary')->rows(4),
DateTime::make('Last Reviewed')->onlyOnDetail(),
]),
Tune the two knobs people actually ask for. Placeholder text and a note limit are the documented customisations and they cover most requests:
Notes::make('Notes')
->onlyOnDetail()
->placeholder('Handover context, decisions, follow-ups…')
->limit(50); // verify the exact setter names against the field class
Read-only mode is the right answer for records under legal hold, closed tickets, or anything where appending is fine but rewriting history is not:
Notes::make('Notes')
->onlyOnDetail()
->readOnly($request->user()->cannot('appendNotes', $this->resource));
Set the authorization boundary yourself — do not assume it is inherited. The inline add-note form is a package-owned endpoint. Nova field visibility and Nova policy authorization are different layers, and a field that renders is not automatically a field that rejects writes. Gate it at both:
Notes::make('Notes')
->onlyOnDetail()
->canSee(fn (NovaRequest $request) => $request->user()->hasAnyRole(['support', 'admin']))
->canRun(fn (NovaRequest $request) => $request->user()->can('update', $this->resource));
Then verify against a real restricted user. If the endpoint still accepts a POST from a user who cannot see the field, that is your decision to make consciously (middleware, policy on the resource, or accepting it).
Append notes from outside Nova — but match the shape exactly. Support tickets, webhooks, and imports will want to push into the same column. Dump one real resolved value first (php artisan tinker → look at $model->notes), then mirror it:
// Whatever the package actually stores — inspect before copying.
$customer->notes = array_merge($customer->notes ?? [], [[
'note' => 'Auto-imported: renewal call logged by Zendesk sync.',
'author_id' => null,
'author' => 'System',
'timestamp' => now()->toIso8601String(),
]]);
$customer->save();
Wrap that in a small appendNote() model method rather than scattering the array literal across jobs. When the package's internal shape changes, you patch one function.
Seed and assert it, so upgrades fail loudly in CI. Empty panels give false confidence. Add a factory state and one assertSee against the detail route:
// CustomerFactory
public function withNotes(): static
{
return $this->state(fn () => ['notes' => [[
'note' => 'Called re: renewal. Waiting on finance.',
'author' => 'Ops',
'timestamp' => now()->toIso8601String(),
]]]);
}
$this->actingAs($admin)
->get(Nova::url("/resources/customers/{$customer->id}"))
->assertOk()
->assertSee('Waiting on finance');
That single test catches the package's most probable failure mode: a silent render break after a Nova or package bump.
Keep the fallback warm and cheap. Because the data lives in a plain JSON column, the escape hatch is a rendering change, not a data migration:
// Swap in one commit if the package goes dormant:
Textarea::make('Internal Notes')
->rows(6)
->hideFromIndex()
->resolveUsing(fn () => collect($this->notes ?? [])->pluck('note')->implode("\n\n"));
You lose the author/timestamp timeline. Decide up front whether that loss is acceptable — if it isn't, you own this dependency and should budget for the fork.
Inventory it at adoption. Record the version and the Nova major you verified against, and fold the re-check into a single "all third-party Nova fields" task at every Nova upgrade instead of discovering breakage on a Tuesday.
Blank or non-interactive field after deploy = assets. php artisan nova:publish must run after composer install on every deploy that bumps the package. Check public/vendor/outl1ne exists and that your build step didn't wipe public/. This is the #1 cause of "works locally" for every Nova field, this one included.
JSON column type matters more than you think. On MySQL use json, not text — you get validation for free and room for the generated-column trick below. If you're on an older MySQL/MariaDB, text + an array cast is fine, but you lose the ability to query into the payload.
You cannot ->sortable() or ->searchable() a JSON blob meaningfully. Nova's search does a LIKE on the column at best, which will match author names and timestamps as happily as note text. If searching notes is a real requirement, add a denormalised mirror column (or a MySQL generated column) and search that:
$table->text('notes_search')->nullable(); // maintained in a model saved hook
If nobody has asked for search, don't build it — but know that this is the first request you'll get after rollout.
The inline endpoint may bypass your field rules. ->rules() on a Nova field applies to the resource form, not necessarily to the package's add-note request. If note length or content matters, validate it in the package's request (fork) or in a saving hook on the model. Test it by POSTing an oversized note and seeing what happens.
Free text in an admin panel is still an XSS surface. The threat model is your own staff, but a compromised low-privilege account injecting markup into a note an admin later views is a real path. Check whether the Vue component renders with v-html or interpolation, and decide explicitly. Don't discover this during a security review.
No pagination is the quiet killer. A customer record touched for three years accumulates hundreds of notes, and the field renders the lot. Load one record with ~200 notes in staging before rolling out to a team that will actually generate that volume. If it degrades, the fix is a fork (cap the render, "show all") — not a config flag.
It is not an audit trail, and staff will try to use it as one. Notes are mutable and deletable. "Who changed this record and when" is spatie/laravel-activitylog or a real event log. Put that in the field's help text if it sits anywhere near compliance:
->help('Internal context only. Not an audit record — edits and deletions are not tracked.')
Debugging a note that won't save — work outward. (1) Does the network tab show a request at all? No request = JS/asset problem. (2) Does it 403/422? Authorization or validation. (3) Does it 200 but not persist? Cast mismatch or a fill() problem — Log::info(json_encode($value)) inside the field's resolve/fill collapses a two-hour hunt into five minutes. Check the JSON column is in $fillable too; a MassAssignmentException swallowed by Nova looks identical to "nothing happened."
Double version coupling is the structural risk. You inherit Nova's major cadence and this package's lag behind it. Never float the constraint across a Nova major upgrade without a staging pass on the one resource that uses the field — and never trust the release date as a health signal; trust the composer.json constraint string.
Extension points are thin by design. This is a narrow field, not a framework. Customisation realistically means forking, and the MIT license plus the small code surface makes that a days-not-weeks exercise. Record the fork point and who owns Nova-compatibility patches at adoption time, not after the first break. At 52 stars and zero published dependents, treat it as vendored source with a public upstream — the honest framing to your governance process is "internal convenience layer, explicitly reversible."
How can I help you explore Laravel packages today?