Icon Registry
resources/js/panel/icons/registry.ts is the build-time allowlist that turns an icon name from the server into a Vue component. It is generated by php artisan panel:icons, it is committed, and it is compiled into the bundle — which makes it a CI concern rather than a deploy step. Reach for this page when adding the check to a pipeline, or when a button in production draws no icon.
A minimal working example
In CI, after installing the npm dependencies:
npm ci
php artisan panel:icons --check2
INFO The icon registry is up to date.Locally, whenever a new ->icon('…') is written:
php artisan panel:icons
npm run build
git add resources/js/panel/icons/registry.ts2
3
Why this is a deployment concern
An icon name is a plain string on the PHP side:
use PandaPanel\Actions\Action;
Action::make('approve')->icon('circle-check');2
3
It resolves through the registry and nothing else. A name that is not a key there renders nothing at all — no error, no placeholder, no console message in production:
export function resolveIcon(name: string | null | undefined): Component | null {
if (typeof name !== 'string') {
return null;
}
if (!isPanelIconName(name)) {
// development only: one warning per unknown name
return null;
}
return ICONS[name];
}2
3
4
5
6
7
8
9
10
11
12
So the failure mode of a stale registry is a live panel with iconless buttons and a clean log. That is why the check belongs in CI, where the answer is available before anything ships.
Where it belongs in a pipeline
| Stage | Command | Why there |
|---|---|---|
| CI | npm ci then php artisan panel:icons --check | the check needs Lucide on disk to validate names against |
| Local development | php artisan panel:icons | it writes a tracked file, which is a commit |
| Deploy | nothing | the registry is already in the repository and already compiled by npm run build |
Do not run panel:icons during a deploy. It writes into resources/js, which on a release-directory deploy means writing into an artefact that is about to be replaced, and the change would never reach the repository. It also needs node_modules present, which a PHP-only deploy step may not have.
- run: npm ci
- run: php artisan panel:icons --check
- run: npm run build2
3
npm ci first, so the Lucide check has something to check against. Without it:
WARN @lucide/vue is not installed; nothing to check names against.Every name is then taken as given — treating them all as unknown would empty the registry, and every icon in the panel would silently disappear because somebody ran the command before installing.
The command
php artisan panel:icons
php artisan panel:icons --check2
panel:icons
{--check : Fail instead of writing, for CI}2
| Option | Default | Effect |
|---|---|---|
--check | off | Writes nothing. Compares what it would write against the file on disk and fails if they differ. |
Exit codes
| Run | Outcome | Code |
|---|---|---|
| default | written, every name known to Lucide | 0 |
| default | written, one or more names not Lucide icons | 1 |
--check | file matches, every name known | 0 |
--check | file matches, unknown names present | 1 |
--check | file differs | 1 |
A default run always writes, even when it found an unknown name — the known icons still belong in the registry. The non-zero exit is about the typo:
ERROR Not a Lucide icon: trahs, user-circle-2That message is the only warning a mistyped icon ever produces. Everything else about it is silence.
What the command reads
Two roots, both walked for .php files:
| Root | Why |
|---|---|
app_path() | the application's panels, resources, pages, widgets, actions |
the package's own src/ | half the icons a panel renders belong to actions the framework ships — delete, edit, export. A scan of app/ alone would rewrite the registry without them, and every built-in action would then render with no icon and no error. |
Names are read from source rather than from booted panels, because an icon can be declared where no runtime walk reaches: a wizard step, a filter tab, a header action built inside a method. Five literal shapes are recognised, plus the body of a method literally named icon(): string.
Available names come from node_modules/@lucide/vue/dist/esm/icons/*.mjs — the icons Lucide actually ships on this machine, rather than a list restated in PHP.
What it writes
PandaPanel\Support\FrontendPaths::panel('icons/registry.ts'), so a project that moved the panel frontend with frontend.panel_path gets the file in the right place:
use PandaPanel\Support\FrontendPaths;
FrontendPaths::panel('icons/registry.ts');
// '/app/resources/js/panel/icons/registry.ts'2
3
4
Names are sorted, so the file is byte-stable across runs and machines — which is what makes --check a meaningful comparison rather than a diff of ordering.
Verifying it from a test
The suite that ships with this repository asserts the registry resolves every icon the panels ask for, including the ones the framework's own actions carry. The same shape works in an application:
it('keeps the registry in step with the icons the source declares', function (): void {
$this->artisan('panel:icons --check')->assertSuccessful();
});2
3
A runtime walk over navigation and table actions catches most of it; the command catches the rest, because it reads the source rather than a booted panel.
Bundle size
Lucide ships 1768 icons. A panel uses a couple of dozen, and only those reach the bundle — the registry is a static object with named imports, so the rest are tree-shaken away. Generating the file changed who maintains the list, not what it guarantees.
That is also why the registry is not a dynamic import keyed on a runtime string: a name resolved from server metadata into the bundle would be metadata reaching into the build, and a dynamic import keyed on a runtime string is not statically analysable in the first place.
Gotchas
- It is a build artifact and it is committed. Regenerating changes a tracked file, which is exactly what makes
--checkmeaningful. - Regenerating is not enough on its own. The file is TypeScript compiled into the bundle, so
npm run buildhas to run after it changes. - Names must be lowercase kebab literals.
'ArrowRight'matches no pattern. An icon name in a constant, or built by concatenation, is invisible to the scan. vendor/beyond this package is not scanned. A plugin shipped as its own package declaring icons in its ownsrc/is not read; declare those names somewhere underapp/, or edit the plugin's published components.- In production an unknown name is simply absent. The development warning is gated on
import.meta.env.DEV, because a console message on a live panel helps nobody and this is a build problem rather than a runtime one. - Running the command in a deploy writes a file nobody will ever commit. Check in CI; generate on a developer's machine.
See also
- Production checklist, Frontend build
panel:icons— every pattern it recognises, in detail- Icons — how a name becomes a component
- Icons troubleshooting
- Component registries
- Frontend paths
- CI matrix