Frontend Toolchain
The other half of this package is Vue and TypeScript under resources/js/, and no PHP job can say anything about it. Four config files decide how it is checked — package.json, eslint.config.js, tsconfig.json and vite.config.ts — and only the first ships, because the installer reads it at runtime. This page is what each one decides and why, which matters because the build here proves something different from the build in an application.
A minimal working example
npm ci
npm run ci2
npm run ci is format:check, then lint, then typecheck, then build — in that order, because a type error is a better message than the bundler's version of the same problem. It is what the blocking CI job runs, across Node 20, 22 and 24.
The scripts
All seven, from package.json:
| Script | Command |
|---|---|
lint | eslint resources/js frontend --max-warnings=0 |
lint:fix | eslint resources/js frontend --fix |
format | prettier --write resources/js frontend resources/css |
format:check | prettier --check resources/js frontend resources/css |
typecheck | vue-tsc --noEmit -p tsconfig.json |
build | vite build |
ci | npm run format:check && npm run lint && npm run typecheck && npm run build |
npm run format # fix formatting
npm run lint:fix # fix what eslint can fix
npm run typecheck # vue-tsc over every component
npm run build # does all of it compile together2
3
4
--max-warnings=0 means a warning is a failure. There is no tier of lint output that CI ignores.
engines declares "node": ">=20.19", which is the floor the CI matrix starts at.
"engines": { "node": ">=20.19" }The package is not an npm package
{
"name": "@chocoalano/panel",
"version": "0.0.0",
"private": true
}2
3
4
5
"private": true at version 0.0.0, never published. The components reach an application through vendor:publish and are built by that application's Vite against that application's dependency tree. This package.json exists to declare what those components are written against and to hold the scripts that check them.
dependencies versus devDependencies is a contract
dependencies is what an application must install for the published components to build. devDependencies is what this repository needs to check them. The distinction is enforced by code rather than by habit:
use PandaPanel\Support\Installer\FrontendRequirements;
FrontendRequirements::npmPackages(); // list<string> of 'name@range' pairs
FrontendRequirements::missingNpmPackages(); // the same, minus what the application declares2
3
4
public static function npmPackages(): array
public static function missingNpmPackages(): array2
npmPackages() reads the dependencies block of this package.json — the real file, at dirname(__DIR__, 3).'/package.json' — rather than restating the list in PHP, because a second copy goes stale the first time a component imports something new. php artisan panel:install prints the result as a literal npm install … line.
So a component that imports something new needs it in dependencies, or the installer tells applications the wrong thing. InstallerTest asserts the two agree:
$declared = json_decode(File::get(dirname(__DIR__, 3).'/package.json'), true)['dependencies'];
expect(FrontendRequirements::npmPackages())->toHaveCount(count($declared));2
3
missingNpmPackages() compares against the application's own package.json, reading both dependencies and devDependencies, because what matters is whether the project has declared the dependency. A transitive copy in node_modules today is one somebody else's upgrade removes tomorrow.
The host seam
The published components import nineteen modules the package does not ship. frontend/host/ holds a minimal stand-in for each, used only when type-checking and building this repository:
frontend/host/
├── actions/ Wayfinder-generated controller actions
├── components/ Heading, UserInfo, UserMenuContent, PasskeyItem, …
├── composables/ useTwoFactorAuth
├── routes/ Wayfinder-generated route modules
└── types/ the application's shared types, and types/ui2
3
4
5
6
Two of them are impossible to ship and the rest would be wrong to. routes/* and actions/* are generated by Wayfinder from the application's own route table, so a copy vendored here would be a snapshot of somebody else's routes. The components are the application's design — UserMenuContent is where a project puts its own account links.
The stand-ins declare the exact props, emits and exports the panel's components use, with no any on that surface, so a stub that drifts from what a starter kit really exports breaks the build here rather than passing and breaking in an application.
The list panel:install checks an application against is the constant in FrontendRequirements:
public static function missingHostModules(): array // list<string> of '@/…' specifiers
public static function hasVite(): bool
public static function missingInertia(): array // what is missing, in words
public static function layoutOverrides(): array // list<array{file: string, line: int, code: string}>2
3
4
A specifier is looked for with six spellings — .ts, .vue, .d.ts, /index.ts, /index.vue, /index.d.ts. A bare '' used to be in that list, which made every directory-shaped entry vacuous: File::exists() answers true for a directory, so @/types was satisfied by the folder this package publishes into and the check could never fail.
@/ resolves in two steps
@/x means resources/js/x, falling through to frontend/host/x. Order is the whole point: a module the package ships resolves to the package's own file, and only the nineteen it genuinely does not ship fall through.
TypeScript gets it from an ordered paths array:
"paths": {
"@/*": ["./resources/js/*", "./frontend/host/*"]
}2
3
Vite cannot express the fall-through with an alias — an alias is one mapping, and two aliases would have the second shadowed by the first for every path — so vite.config.ts implements the same two-step as a plugin:
const SOURCE_ROOTS = ['resources/js', 'frontend/host'] as const;
const EXTENSIONS = ['', '.ts', '.vue', '/index.ts', '/index.vue'] as const;
function hostSeam(): Plugin {
return {
name: 'panda-panel:host-seam',
enforce: 'pre',
resolveId(source) {
if (!source.startsWith('@/')) {
return null;
}
const relative = source.slice(2);
for (const base of SOURCE_ROOTS) {
for (const extension of EXTENSIONS) {
const candidate = resolve(root, base, `${relative}${extension}`);
if (isFile(candidate)) {
return candidate;
}
}
}
return null;
},
};
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
The isFile() check is load-bearing. The empty extension is tried first so @/lib/utils.ts resolves as written — but @/components/ui/button is a directory with that exact path, and the specifier means its index.ts. Answering "yes, that exists" for a directory reads a barrel import as a file.
Both sides derive from the same list, so the bundler and the type-checker never disagree about what a specifier points at. Changing one means changing the other.
The build is a compile check
Nothing npm run build produces is shipped. What it is for is the question no amount of type-checking answers: does every one of these files actually resolve and compile together?
export default defineConfig({
plugins: [hostSeam(), vue(), tailwindcss()],
build: {
outDir: 'build/frontend',
emptyOutDir: true,
minify: false,
rollupOptions: {
input: resolve(root, 'frontend/entry.ts'),
},
},
});2
3
4
5
6
7
8
9
10
11
| Option | Value | Why |
|---|---|---|
outDir | build/frontend | Under build/, gitignored, so rm -rf build is a complete clean. |
emptyOutDir | true | A stale artefact from a deleted component would otherwise linger. |
minify | false | A minifier pass is a minute spent making an artefact smaller that is about to be deleted. |
input | frontend/entry.ts | Generated rather than authored. |
The entry globs the whole tree instead of naming components:
const modules = {
...import.meta.glob('../resources/js/**/*.vue', { eager: true }),
...import.meta.glob('../resources/js/**/*.ts', { eager: true }),
};
export default Object.keys(modules).sort();
import '../resources/css/panda-panel.css';2
3
4
5
6
7
8
eager: true so Rollup has to resolve, parse and compile each module rather than emitting a lazy chunk that would only fail at runtime, in a browser this build never reaches. The keys are exported so nothing is tree-shaken away before it has been compiled. The stylesheet is imported because it is Tailwind 4, where the theme, the custom variants and the @source scan all live in CSS rather than in a config file — a broken one is as much a build failure as a broken component.
A hand-written list of imports would compile a hand-written list of components, and a build that silently stopped covering new files is worse than no build.
Where files live
@inertiajs/vite only globs resources/js/pages/**, which fixes the layout:
| Directory | Holds |
|---|---|
resources/js/panel/** | building blocks — never Inertia pages |
resources/js/pages/panel/** | framework-generic Inertia pages |
resources/js/pages/Panels/{Panel}/** | application-specific pages and custom widget components |
resources/js/components/ui/** | vendored from shadcn-vue |
resources/js/composables, lib, types | shared helpers and the shared prop types |
resources/js/components/ui/** is left in shadcn-vue's own formatting. Prettier ignores it and ESLint relaxes four rules there, because these are the files an application is most likely to re-pull from shadcn-vue directly and a house rule would turn every upstream update into a diff about whitespace. They are still type-checked and still built, which is what actually catches a breakage in them.
Build-time registries
Icons and custom widget components resolve only through registries built at compile time. A name that was not compiled in cannot be reached, whatever a request says.
php artisan panel:icons # rewrite resources/js/panel/icons/registry.ts from the source
php artisan panel:icons --check # fail instead of writing, for CI2
Never edit registry.ts by hand — it says so in its own header. Add an icon by writing the Lucide name in PHP and running the command; names are validated against node_modules/@lucide/vue/dist/esm/icons/*.mjs, so a typo fails the command by name instead of shipping a button with no icon.
Two rules about writing a registry:
- Never pass the
@alias toimport.meta.glob. Vite's dev server resolves an aliased pattern to nothing — the module is literallyObject.assign({})— while the production build resolves it normally. Custom widgets therefore rendered the fallback in dev and worked once built. Use a relative pattern:'../../pages/Panels/**/Widgets/*.vue'. - Derive the lookup key from the real path. The key format follows the pattern as written and differs between dev (
../../pages/...) and build (./pages/...), so map overObject.entries(modules)rather than reconstructing a key from a name.
Nothing here ships
.gitattributes marks every one of these files export-ignore:
/frontend export-ignore
/package-lock.json export-ignore
/tsconfig.json export-ignore
/vite.config.ts export-ignore
/eslint.config.js export-ignore
/.prettierrc.json export-ignore
/.prettierignore export-ignore2
3
4
5
6
7
An application installs the components and builds them with its own toolchain, from the version ranges rather than from this repository's lockfile. Adding a config file at the repository root therefore means adding an export-ignore line for it.
There is one file that looks like it belongs on that list and is deliberately kept off it: package.json, which FrontendRequirements reads at runtime from inside vendor/ to tell an application which npm packages the published components import. Export-ignoring it did not make panel:install complain — it made the check return an empty list. Negative/DistributionTest now asserts the attribute, and Releases covers the whole of it.
Gotchas
npm cifails rather than resolving when the lockfile disagrees withpackage.json. That is intended: a change to a range without regenerating the lockfile is a change nobody has run.- The lockfile is committed and an application never sees it. CI runs
npm cihere for reproducibility, and a separate non-blocking job runsnpm install --no-package-lockto catch what applications actually hit —^4.1.0inpackage.jsonresolving to something newer than the lockfile's pin. - A new panel asset entrypoint is two edits.
Panel::assets(...)appends it to the application's Vite entrypoints, and the path must also be added tovite.config.ts'sinputor the page dies with a manifest error. - Never interpolate a Tailwind class.
md:col-span-${n}is in no file Tailwind scanned, so it is in no bundle. Map through a literal record. - A deferred Inertia prop must be an optional Vue prop. A prop shipped with
Inertia::defer()is absent from the first response, not null; declaring it required makes Vue warn twice on first paint. vue/multi-word-component-namesis off on purpose.DataTableandActionModalare the framework's names to use.build/frontendis deleted byrm -rf build, along with the PHPStan cache and the Testbench storage. Nothing in there is worth keeping.
See also
- Local development — the setup, and both toolchains side by side
- Coding standards — the ESLint, Prettier and TypeScript settings, rule by rule
- Releases — the export list, and the file that has to reach the dist
- Running the tests — including the tests that assert on frontend files
- Host modules — the nineteen, from an application's side
- Frontend assets and assets
- Component registries
- Icons and
panel:icons - CI matrix — the two frontend jobs and what each proves