rmsj/nova-hero-icon
Integrates the Heroicons SVG icon set into Laravel Nova, letting you add crisp, ready-made icons to your admin panel resources and custom tools without bundling or managing the assets yourself.
1. Confirm the contract before you write a line of integration code.
The package description and the actual class shape disagree in most write-ups: some call it a Nova field, others a resource tool. They are not interchangeable — Laravel\Nova\Fields\Field registers via fields(), Laravel\Nova\Tool registers via tools(), and the fluent API differs. Resolve this in five minutes:
cat vendor/rmsj/nova-hero-icon/composer.json # php + laravel/nova constraints = go/no-go
grep -R "extends" vendor/rmsj/nova-hero-icon/src
grep -R "public function" vendor/rmsj/nova-hero-icon/src
composer.json is the real gate. If it pins laravel/nova to 2.x/3.x or php to 7.x, and you are on Nova 4/5 with PHP 8.2, stop here. The Vue 2 component will not mount into a Vue 3 Nova, and nothing upstream is coming to fix it.
2. Install and publish.
composer require rmsj/nova-hero-icon
php artisan vendor:publish --tag=nova-hero-icon-assets
# fallback if the tag isn't registered
php artisan nova:publish
Check public/vendor/ afterward to confirm the JS/CSS bundle actually landed.
3. Register it in the shape you confirmed in step 1.
Field shape:
use Rmsj\NovaHeroIcon\HeroIcon;
public function fields(Request $request)
{
return [
HeroIcon::make('Icon', 'icon'),
];
}
Tool shape:
public function tools(Request $request)
{
return [
HeroIcon::make('Status Icon')
->icon('check-circle')
->size(20)
->color('#22c55e'),
];
}
Do not assume ->icon(), ->size(), or ->color() exist — verify against the public function grep above before chaining. A fluent call to a missing method is a fatal error at render time, not install time.
4. Add the storage column up front.
Both shapes ultimately persist a string (the icon name). You need the column before the first save:
// migration
$table->string('icon')->nullable();
// model
protected $fillable = ['icon'];
Where to look first, in order:
vendor/rmsj/nova-hero-icon/composer.json — the compatibility gate. Read it before anything else.vendor/rmsj/nova-hero-icon/src — the class, its make() signature, and which fluent methods genuinely exist.vendor/rmsj/nova-hero-icon/resources/js — the Vue component's props. This is the authoritative list of what the PHP side can pass through.README.md — publish tag and registration snippet.First use case: let an editor pick a Heroicon on a Category, MenuItem, or Status record, store the name, and render it back on the detail screen and index table. Treat it as an editor-convenience affordance, not infrastructure. If the requirement grows past "pick a name, store it, display it," reassess rather than extend.
5. Run a spike before committing. Install it in a throwaway branch, open one resource form, save an icon, confirm it renders on the detail screen. Fifteen minutes of spike beats an afternoon of debugging a blank panel.
Store-and-render loop, with the render side yours. The package's job ends at persisting a string. Keep the read path decoupled so replacing the package later is a one-file change:
// app/Nova/YourResource.php — editor-facing
HeroIcon::make('Icon', 'icon'),
{{-- resources/views/components/heroicon.blade.php -- no dependency on the package --}}
@props(['name' => 'question-mark-circle'])
<svg {{ $attributes->merge(['class' => 'w-5 h-5']) }} aria-hidden="true">
<use href="#icon-{{ $name }}"></use>
</svg>
Inline the SVGs into your own Blade component (or a sprite partial). When the picker breaks on a Nova upgrade, your display layer — the part users see on every index and detail page — is untouched.
Centralize the icon-name whitelist. Build the known set once by scanning the package's bundled SVGs, commit it as a config file, and never read the vendor directory at runtime:
// config/icons.php
return [
'available' => [
'check-circle', 'x-circle', 'exclamation', 'information-circle',
'user-circle', 'annotation', 'dots-circle-horizontal', // this revision's names
// ...
],
'fallback' => 'question-mark-circle',
];
Then resolve every stored value through one helper:
public static function resolveIcon(?string $name): string
{
$known = config('icons.available', []);
return in_array($name, $known, true) ? $name : config('icons.fallback');
}
Validate anything user-controlled. If the value can arrive via API import, CSV, a Nova action, or a seeder, whitelist it. An invalid name renders nothing — no exception, no log entry — which is the hardest failure mode to trace after the fact:
'icon' => ['nullable', 'string', Rule::in(config('icons.available'))],
Split the field for form vs. index. Use the package on the form; hand-roll the index column so the table doesn't inherit the package's presentation quirks (and so it respects dark mode):
public function fields(Request $request)
{
return [
HeroIcon::make('Icon', 'icon')->onlyOnForms(),
Text::make('Icon', fn () => $this->icon
? view('components.heroicon', ['name' => $this->icon])->render()
: '—'
)->asHtml()->onlyOnIndex(),
];
}
Contain the blast radius. Attach it to one or two resources — categories, statuses, menu items. Because the package is unmaintained, every resource you bolt it onto is a file you patch on the next Nova major. If it breaks, you fix one place, not fifteen.
Vendor or fork the moment you commit. Do not leave a dead dependency resolving from Packagist on every composer install:
composer require rmsj/nova-hero-icon:dev-main
# then either commit vendor/rmsj/nova-hero-icon into your repo, or
# fork into packages/nova-hero-icon with a path repository:
{
"repositories": [
{ "type": "path", "url": "packages/nova-hero-icon" }
]
}
Pin the commit. Add the fork to your Nova-upgrade checklist. The "one composer require" framing hides the fact that on day one you become the maintainer.
Reference-implementation path — often cheaper. The Vue component is small. Scaffold a fresh tool against your actual Nova version and reimplement the picker in roughly half a day:
php artisan nova:tool your-vendor/icon-picker
You get current Heroicons names, a Vue 3 component, no fork obligation, and no 2020 Node toolchain to resurrect. For a two-year horizon this is usually the better trade.
Seed and backfill deliberately. Legacy rows have no icon. If the UI depends on one being present, either backfill with the fallback value in a migration or render the fallback on read. Don't let nulls silently render empty space.
Version matrix is a three-way lock. Nova major ↔ Vue major ↔ PHP/Laravel. Nova 4 moved to Vue 3 and changed the tool/asset contract; a 2020-era tool will not mount reliably. Compatibility must be proven by spike, not inferred from semver — Nova's tool API is an internal contract with thin documentation.
Blank panel = version mismatch, not your bug. When it "doesn't work," open the browser console first. Vue 2 components mounted into a Vue 3 app throw there, not in PHP logs. Do not burn hours in storage/logs/nova.log for a front-end mount failure.
Icon names are frozen at a 2020 Heroicons revision. Names like user-circle, annotation, and dots-circle-horizontal changed across Heroicons releases. Editors copying names from current heroicons.com will save values that render nothing. Commit the bundled name list and use it as both the UI source and the validator, so the two cannot drift.
Silent failure is the default. Invalid icon name → empty SVG → no exception, no warning, no log line. The editor believes it saved. Counter this with the Rule::in() whitelist plus a visible fallback icon so bad data surfaces immediately instead of festering in the database for six months.
Verify assets ship compiled. Inspect resources/js for a dist/ folder or a nova.mix.js. If it needs a 2020 Node toolchain (old laravel-nova, old vue-loader), you inherit a rebuild obligation on top of the fork. If compiled assets ship in the package, you avoid the toolchain — but you can't patch the Vue side without reconstructing it.
Asset-publish collisions. php artisan nova:publish from multiple Nova tools can overwrite each other's bundles. If admin JS/CSS goes sideways after install, diff public/vendor/ for duplicate or unexpectedly replaced files. Publish tools one at a time and commit the result so you can see what changed.
Theme tokens, not hardcoded fills. SVG with fill="#000" vanishes in Nova's dark mode. If the package's markup hardcodes colors, override in your own display component — another reason the read side should be yours.
There is no support surface. Seven stars, zero dependents, no issue history, no Stack Overflow answers, no blog posts. Troubleshooting is first-principles only: read the Vue props, read the PHP class, inspect the rendered DOM. For a cosmetic convenience that's tolerable. If a stakeholder calls it broken, you own the fix entirely — there is no upstream to escalate to.
Extension points are minimal by design. Realistically you have three: (1) the fluent PHP methods the class actually exposes, (2) the props the Vue component accepts, (3) your own Blade render component. The first two are frozen; the third is where you should spend any customization effort. Don't try to monkey-patch the package's Vue bundle — you'll lose the patch on the next nova:publish.
Budget the real cost. Half a day to spike, an hour to vendor/fork cleanly, and a recurring half-day at every Nova and Laravel major. If nobody on the team will own that half-day, don't start — a Nova Select field populated from config('icons.available') plus your own <x-heroicon> component matches the effort and remains permanently maintainable.
How can I help you explore Laravel packages today?