khalin/nova-link-field
A Laravel Nova field for adding clickable link inputs to your resources. khalin/nova-link-field makes it easy to display and manage URLs alongside model fields in Nova admin panels.
Install and let the resolver be your first test. There is no service provider, no config file, no publish step, and no migration — the package is a single Field subclass plus a compiled Vue component that Nova loads through its own asset mechanism. That means the entire integration surface is one line in composer.json and one line in fields().
composer require khalin/nova-link-field
Run it without --ignore-platform-reqs and without widening constraints. The last tagged release is 2020-05-17, which puts it in the Nova 2.x/3.x, Laravel 6/7, PHP 7.2–7.4 window. If the resolver refuses on your stack, that is the finding — don't force it (see Gotchas). If it resolves, you're on an older Nova and can proceed.
Where to look first, in this order:
src/Link.php — the field class. Ground truth for the $component string, the constructor signature, and which fluent setters actually exist (label? text? target?). The README is short and may lag the code.resources/js/components/ — the Vue component. Two questions: does it render an editable input on forms, or is it display-only? And does it emit the label via v-text (safe) or v-html (an XSS escape hatch)?dist/js/field.js — what Nova actually loads at runtime. If dist/ is older than resources/js/, you are running whatever was compiled at that tag, not what you just read.First use case: one resource, one column, three views. Take a resource with a full URL stored in a text column and swap the existing field:
use Khalin\NovaLinkField\Link;
public function fields(Request $request)
{
return [
ID::make()->sortable(),
Text::make('Name'),
Link::make('Website'), // stored column rendered as a clickable anchor
];
}
Then check index, detail, and form individually. A field this small frequently nails the detail view and breaks the other two — if the form view shows a static anchor instead of an input, you've found the field's single most important limitation in about two minutes. That's the whole smoke test, and it's worth doing before you touch a real resource.
The main value-add over Text::make()->displayUsing(...) is separating readable text from the stored URL:
// Same value serves as both URL and label
Link::make('Website'),
// Human label, machine URL — confirm the exact setter in src/Link.php
Link::make('Website')->label('Visit site'), // or ->text(...) depending on version
If the package only exposes one value, keep the label in a second column and resolve it in the field rather than denormalizing the label into the URL column.
The field renders whatever the column resolves to, so normalize in the field and leave the raw column clean for filters, exports, and API consumers:
Link::make('Domain')
->resolveUsing(fn ($value) => $value ? 'https://' . $value : null),
This is also where you strip trailing whitespace from copy-pasted values — a very common source of broken anchors in admin tools.
Because it's just an anchor, mailto: and tel: work identically, which is genuinely useful for support resources where operators want one-click contact:
Link::make('Support Email')
->resolveUsing(fn ($value) => $value ? "mailto:{$value}" : null),
Treat this as a deliberate per-field opt-in. Blanket-prefixing a column that already contains arbitrary values is how you get mailto:https://....
If the component turns out to be display-only, do not expose it on forms. A display-only field in a form view can submit an empty or stale value and silently overwrite the column on save. The pattern that gives you the clickable UX with zero write-path risk:
Link::make('Website')
->onlyOnIndex()
->onlyOnDetail(),
Text::make('Website')
->onlyOnForms(),
You get clickable links in the two read views and a proven, boring input on the edit view. This should be your default until you have personally confirmed the component hydrates an input.
Long URLs are painful to click-and-drag, and index columns truncate. On detail views, park a copyable plain text field next to the link:
Link::make('Website')->onlyOnDetail(),
Text::make('Website')->onlyOnDetail()->copyable(),
Costs nothing, covers operators who need the string rather than the navigation.
If you end up with more than two or three of these, wrap it so the target/rel policy lives in one file and Nova upgrades touch one place:
// app/Nova/Fields/SafeLink.php
class SafeLink extends \Khalin\NovaLinkField\Link
{
public function __construct($name, $attribute = null, $resolveCallback = null)
{
parent::__construct($name, $attribute, $resolveCallback);
$this->target('_blank');
// rel handling goes here — or in the Vue component if the PHP side can't express it
}
}
Fifteen scattered ->target('_blank') calls are fifteen places to audit the day you discover rel="noopener" isn't being emitted.
Nova fields are hard to assert in PHPUnit because rendering happens in Vue, so the practical loop is manual and takes about 90 seconds:
laravel.log.javascript:alert(1) value into the column and confirm it does not execute on click.A display-only field on a form is worse than no field: Nova may render it non-editable while the form still submits a value for the attribute. If the component doesn't hydrate an input, you can blank the column on save. Verify on a throwaway record before shipping to a real resource. If you can't confirm it, use the onlyOnIndex/onlyOnDetail + Text::make()->onlyOnForms() split above and move on.
A field whose entire job is rendering admin-supplied URLs must reject javascript: and data: schemes and must emit rel="noopener noreferrer" whenever target="_blank". Whether this package does either is not documented, and the maintainer has been inactive since May 2020. Assume you must verify, and expect to patch.
The attack surface is admin-only, but "only admins" is not a boundary you want to lean on in a support tool where non-engineers paste URLs from external tickets and emails.
The fix belongs in the Vue component, not the PHP class. Sanitization in resolveUsing is bypassable by anything that sets the attribute later (accessors, other fields, fillUsing), and the DOM is where the decision actually matters.
Nova 4 (2022) moved to Vue 3 + Inertia and reworked field asset registration; Nova 5 continues that. Expect composer.json to block install outright. If someone forces it with --ignore-platform-reqs, expect one of three outcomes:
That third case is why you trial this on one low-traffic resource, never in a batch.
dist/ may not match resources/js/Nova loads the compiled bundle. If you fork to patch sanitization, you must run the package's build and commit the new dist/js/field.js. Check whether the package ships prebuilt assets or expects a build step — if the latter, add it to CI. Editing the Vue source and forgetting to rebuild is the single most common "my fix didn't work" in Nova custom fields.
An empty column typically produces <a href="">Label</a>, which on click reloads the current Nova page and reads as a broken app rather than empty data. Guard it:
Link::make('Website')
->resolveUsing(fn ($value) => $value ?: null)
->hideIf(fn ($resource) => empty($resource->website)),
A one-line v-if in the component is the cleaner fix if you're forking anyway.
Work down this list in order:
$component string in src/Link.php matches the component name registered in the JS.dist/js/field.js exists and is registered. Nova 4+ uses Nova::script(...) in a service provider — this package ships no service provider, which is itself the failure on Nova 4+.->displayUsing(fn ($v) => var_dump($v)) to confirm the column is populated and the resolver actually fires.null — see the blank-value case above.This is an anchor and nothing more. Long URLs will break index column layout; CSS truncation on the container is on you. It almost certainly isn't meaningfully sortable(), and filtering/searching a URL column through a display field is unreliable — keep the raw column available for filters.
Zero dependents, 31 stars, one release, one maintainer. There is no upstream to receive fixes from; anything you adopt becomes an internal artifact. If you proceed:
composer.json.Link field extending Laravel\Nova\Fields\Field with a displayUsing callback plus a ~20-line Vue component reproduces this behavior with zero version risk. Treat the package as a reference implementation for exactly that.If the install doesn't resolve cleanly on your Nova version, stop. Don't widen constraints, don't inline-alias, don't replace. The blast radius of a bad field is small — delete one line from fields() — but the maintenance tax is permanent, and the in-house replacement is genuinely a few hours of work.
How can I help you explore Laravel packages today?