Version v1.6.5 Older versions: docs on GitHub
Extensions: the public API
On this page
- Concepts
- Trust model
- Quick start
- Package layout
- Registration: Larsadmin::extension()
- An extension inside the application
- defineExtension() and requires
- Custom fields
- The PHP class
- Vue: form
- Vue: display
- Fields that upload files
- Save hooks: fillInto() and FileJournal
- Audit: hasAuditValue() and auditValue()
- Binding to the entity: bindTo()
- A field in an action modal
- Cards
- A static card
- DataCard
- entityQuery()
- Caching
- Vue: useCardData() and CardShell
- Filters
- Tools: sidebar pages
- Extension routes
- findRecord()
- findField()
- audited()
- Translations
- Styles and theming
- The public JS API
- The larsadmin module
- The author kit (vendor/maxcxam/larsadmin/kit)
- Overriding built-in components
- What can be overridden
- The contract
- Security and fallback
- Unavailable and crashed extensions
- CSP and nonces
- Versioning and compatibility
- Checking the public API
- Testing
- Pest (PHP)
- vitest (Vue)
- Building and publishing
- Troubleshooting
- HTTP endpoints
- Limitations
Since 1.5 the panel has a public extension API. An extension is a regular Composer package with PHP classes and built Vue components. It adds your own fields, dashboard cards, filters, sidebar pages (tools), HTTP routes and translations, and it can replace the UI of a few built-in components.
An extension is installed like this:
composer require acme/larsadmin-rating
The host needs no npm, no vendor:publish and no config changes: the package registers itself in its
service provider and the panel serves its files. Only the extension author needs npm, to build the Vue
components.
Everything on this page is stable 1.x API (see Versioning and compatibility).
What you can extend without JavaScript (entities, actions, metrics, events) is described in
Extending. Version 1.6 added record abilities for fields (authorizedTo()), extension
fields in action modals (inAction(), actionValue(), the action prop) and the public
Files\FileSafety; an extension that uses them declares requires: '^1.6'.
Concepts
| What | PHP | Vue (in defineExtension()) |
|---|---|---|
| Field | a subclass of Maxcxam\Larsadmin\Fields\Field with $component = 'acme/rating' |
fields: { 'acme/rating': { form, display } } |
| Static card | a subclass of Maxcxam\Larsadmin\Metrics\Card |
cards: { 'acme/welcome': Welcome } |
| Data card | a subclass of Maxcxam\Larsadmin\Metrics\DataCard |
cards: { 'acme/top-rated': TopRated } + useCardData() |
| Filter | a subclass of Maxcxam\Larsadmin\Filters\Filter with $component = 'acme/stars' |
filters: { 'acme/stars': StarsFilter } |
| Page (tool) | a subclass of Maxcxam\Larsadmin\Tools\Tool |
tools: { 'acme/backups': BackupsPage } |
| Override | (nothing) | fields: { 'date-field': { form: Picker, override: true } } |
Server and client are matched by the component key. Extension keys look like vendor/name
(^[a-z0-9-]+\/[a-z0-9-]+$); keys without / belong to the panel. The larsadmin/ prefix is reserved for
first-party packages (such as the sandbox demo extension), app/ for an extension inside the application.
How it works: the panel builds Vue, Inertia and its SDK as separate modules and adds an
import map to the page that maps
the vue, @inertiajs/vue3 and larsadmin specifiers to them. An extension is built as an ES module that
leaves those imports external. So the panel and every extension work with one Vue and Inertia instance:
ref, provide/inject and usePage() in an extension see the same page as the panel.
Trust model
An installed extension is trusted code. Its PHP already runs with the full rights of the application, and its JavaScript runs on panel pages with the rights of the signed-in administrator: it can read the page props and make any request the user can make. Install only extensions you trust, as with any other package.
The security boundary stays on the server: the server authorizes all data and actions (policies,
canSee(), indexQuery()/detailQuery() scoping, validation). Extension JavaScript cannot bypass these
checks any more than the user can in DevTools. Hence the main rule for authors: every extension route
that works with a record authorizes it on the server with Larsadmin::findRecord().
Only users who pass the panel gate (viewLarsadmin) receive extension code. Guests and the login page load
no extension file.
What else the panel does:
- when serving an extension file it checks the resolved path (
realpath), not only the requested one, so alternative names (for example Windows 8.3 short names such asVITE~1) do not open dotfiles or files outside the allowed ones; - the generators (
larsadmin:extension,:field,:card,:tool, and since 1.6 everylarsadmin:*command that creates classes and thegenerateMCP tool) check the whole plan before the first write and refuse to write outside the application root, when the path below it contains any symbolic link or junction (dangling ones included) or a segment of dots only or ending in a dot; an extension package directory cannot be invendor,node_modules,storage,bootstrap,publicor a dot-folder (case-insensitively), andgeneratecreates extensions only belowpackages/orextensions/; useJsonRequest()adds the CSRF token to same-origin requests only.
An extension that stores or serves files uses the public Maxcxam\Larsadmin\Files\FileSafety (since 1.6):
the same upload checks and response headers as the built-in File/Image
(Files and images). Do not copy the panel's internal security code;
if a copy cannot be avoided, cover it with parity tests against the original, so it does not drift after
a panel update.
Quick start
An artisan command creates the package skeleton with a working example field and card:
php artisan larsadmin:extension acme/larsadmin-rating
The command writes the package to {--path}/{vendor}/{package}, i.e. packages/acme/larsadmin-rating/,
and prints the next steps:
composer config repositories.acme-larsadmin-rating path packages/acme/larsadmin-rating
composer require acme/larsadmin-rating:@dev
cd packages/acme/larsadmin-rating && npm install && npm run build
The skeleton's provider registers the extension only when dist/ exists, so after the build it is registered. Add the example field to an entity and the card to a
dashboard:
use Acme\LarsadminRating\Cards\ExampleCard;
use Acme\LarsadminRating\Fields\ExampleField;
ExampleField::make('Example field', 'example_field'), // in an entity's fields()
ExampleCard::make(), // in a dashboard's or an entity's cards()
Done: the form shows the extension field and the dashboard shows the card with data from its endpoint.
Command options:
| Option | Default | Purpose |
|---|---|---|
--path= |
packages |
directory relative to the application root; the package goes to PATH/VENDOR/PACKAGE |
--field= |
ExampleField |
class of the example field; key {vendor}/example-field |
--card= |
ExampleCard |
class of the example DataCard; key {vendor}/example-card |
--force |
overwrite existing files |
The larsadmin:field, larsadmin:card and larsadmin:tool commands add single pieces, with the
--extension= option (the package directory relative to the application root):
php artisan larsadmin:field Rating --extension=packages/acme/larsadmin-rating
php artisan larsadmin:card TopRated --data --extension=packages/acme/larsadmin-rating
php artisan larsadmin:tool Backups --extension=packages/acme/larsadmin-rating
| Command | Creates |
|---|---|
larsadmin:field {name} |
src/Fields/{name}.php, resources/js/fields/{name}Input.vue, {name}Display.vue |
larsadmin:card {name} [--data] |
src/Cards/{name}.php (Card, with --data a DataCard), resources/js/cards/{name}Card.vue |
larsadmin:tool {name} |
src/Tools/{name}.php, resources/js/tools/{name}Page.vue |
The component key comes from the package vendor: acme/rating, acme/top-rated, acme/backups. The
commands do not edit extension.js; they print the lines for defineExtension() (and, for a tool, the
Larsadmin::tools([...]) line for the provider). Without --extension the class goes to
app/Larsadmin/{Fields,Cards,Tools}, the Vue files to resources/larsadmin/js/..., and the key looks like
app/rating (see An extension inside the application).
Developing against a panel branch. The skeleton requires
maxcxam/larsadmin: ^1.6. When the panel is installed from a branch or a path repository, Composer sees it asdev-…: themainbranch has thebranch-aliasdev-main → 1.x-dev, and for a path repository set"options": {"versions": {"maxcxam/larsadmin": "1.6.x-dev"}}. The command warns about this and suggestscomposer require … :@dev -W.
Package layout
packages/acme/larsadmin-rating/
├── composer.json # require maxcxam/larsadmin ^1.6; dev: orchestra/testbench, pestphp/pest
├── package.json # dev: vite, @vitejs/plugin-vue, vue, @inertiajs/vue3, tailwindcss,
│ # @tailwindcss/vite, vitest, jsdom, @vue/test-utils
├── vite.config.js # plugins: [vue(), tailwindcss(), larsadmin()]
├── src/
│ ├── RatingServiceProvider.php
│ ├── Fields/ExampleField.php
│ └── Cards/ExampleCard.php
├── resources/
│ ├── js/
│ │ ├── extension.js # export default defineExtension({...})
│ │ ├── fields/ExampleFieldInput.vue, ExampleFieldDisplay.vue, ExampleFieldInput.test.js
│ │ └── cards/ExampleCard.vue
│ └── css/extension.css # Tailwind utilities + kit/theme.css
├── lang/{en,uk}/ui.php
├── tests/ # Pest.php, TestCase.php, Fixtures/, FieldTest, CardTest, HttpTest
├── dist/ # output of npm run build; committed
└── README.md
The skeleton's vite.config.js looks for the panel kit (kit/vite.js, kit/sdk.js, kit/testing.js,
kit/theme.css) first in the package's own vendor/ (after composer install in its directory), then in
the application's vendor/, and sets the larsadmin-kit alias to the directory it found. The kit ships in
the panel's Composer archive (vendor/maxcxam/larsadmin/kit); there is no separate npm package.
dist/ is committed, like the panel's own dist/: a host that installs the extension never runs npm.
Registration: Larsadmin::extension()
An extension registers itself with one call in its provider's boot():
namespace Acme\LarsadminRating;
use Illuminate\Support\ServiceProvider;
use Maxcxam\Larsadmin\Larsadmin;
class RatingServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadTranslationsFrom(__DIR__.'/../lang', 'acme-rating');
Larsadmin::extension('acme/larsadmin-rating', __DIR__.'/../dist')
->translations('acme-rating');
}
}
Larsadmin::extension(string $name, string $path): Extension takes the Composer package name and the
absolute path of the directory with the built files. By default the panel loads dist/extension.js and,
when it exists, dist/extension.css. The call throws InvalidArgumentException when:
$nameis not a valid Composer package name (vendor/package);$pathis empty, relative, missing, or is itself a symbolic link (a junction on Windows);- the name is already registered with another directory.
Calling it again with the same name and directory returns the same object (providers may boot twice under Octane and in tests). There is no separate "disable extension" switch: registering is enabling, and an extension is removed through Composer.
Fluent methods of Maxcxam\Larsadmin\Extensions\Extension:
| Method | Purpose |
|---|---|
script(string $file = 'extension.js') |
entry module relative to $path |
style(?string $file = 'extension.css') |
stylesheet; null disables it, a missing file is skipped |
translations(string $namespace, string $group = 'ui') |
expose trans("{$namespace}::{$group}") to JavaScript (see Translations) |
version(): string |
12 chars of the md5 of .vite/manifest.json (or of the entry script and stylesheet); part of the file URLs |
url(string $file): string |
versioned URL of a file in $path |
$name, $path |
name and realpath of the directory (readonly) |
There are also Larsadmin::registeredExtensions() (all of them, in registration order) and
Larsadmin::extensionFor($name).
The files are served by the panel route GET {path}/extensions/{vendor}/{package}/{version}/{file} straight
from the registered directory, without publishing to public/. The version is in the path, so relative
imports of the extension's chunks get it too, and the browser caches the files for a year (immutable).
When the version changes (composer update), so does the panel's Inertia version: open tabs reload on their
next navigation. Details in HTTP endpoints.
An extension inside the application
The same mechanism works without a package. The application registers a directory in its
app/Providers/LarsadminServiceProvider.php:
public function boot(): void
{
parent::boot();
Larsadmin::extension('app/admin', resource_path('larsadmin/dist'));
}
and builds resources/larsadmin/js/extension.js with a separate Vite config and the same preset:
// vite.larsadmin.config.js; build: npx vite build -c vite.larsadmin.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import larsadmin from './vendor/maxcxam/larsadmin/kit/vite.js'
export default defineConfig({
plugins: [vue(), larsadmin({ entry: 'resources/larsadmin/js/extension.js', outDir: 'resources/larsadmin/dist' })],
})
larsadmin:field, larsadmin:card and larsadmin:tool without --extension create their files here, with
app/… keys. The name in defineExtension() must be 'app/admin'.
defineExtension() and requires
The extension's entry module has a default export with the result of defineExtension():
import { defineExtension } from 'larsadmin'
import '../css/extension.css'
import RatingInput from './fields/RatingInput.vue'
import RatingDisplay from './fields/RatingDisplay.vue'
import TopRatedCard from './cards/TopRatedCard.vue'
import StarsFilter from './filters/StarsFilter.vue'
import BackupsPage from './tools/BackupsPage.vue'
export default defineExtension({
name: 'acme/larsadmin-rating', // the same as in Larsadmin::extension()
requires: '^1.6', // range of the panel API
fields: {
'acme/rating': { form: RatingInput, display: RatingDisplay },
},
cards: { 'acme/top-rated': TopRatedCard },
filters: { 'acme/stars': StarsFilter },
tools: { 'acme/backups': BackupsPage },
setup({ app }) {
// optional: app.component(), app.directive(), Vue plugins
},
})
defineExtension()only checks the shape of the object (error: aTypeErrorwith the extension name) and marks it. The panel's loader registers the components.namemust equal the name given toLarsadmin::extension(), or the extension is not loaded.requiressupports only the^MAJOR.MINORform. It is satisfied when the major equals the panel'sapiVersionand the panel's minor is not lower.'^1.6'works with API1.6,1.7, … and not with1.5or2.0. An extension that does not use the 1.6 API may keep'^1.5'. An incompatible extension is not loaded and its fields fail closed.- Field parts:
form(create and update forms),display(index and detail), optionalindexanddetail(replacedisplayin their context) andpanel(a separate block on the detail page, see Display). setup({ app })runs once, when the Vue app exists. An error insetupmarks the extension as not loaded and removes all its keys.- Components can be loaded lazily:
defineAsyncComponent(() => import('./Heavy.vue')). The extension's chunks load relative to its URL. - A key without
/or taken by another extension is rejected withconsole.error; the rest of the extension works. The first registration of a key wins. - A built-in key without
override: trueis rejected withconsole.warn(see Overrides).
Without a build step an extension can only use render functions (h()): the panel ships Vue without the
template compiler.
Custom fields
The PHP class
A custom field is a subclass of Field (or Text, Number, …) with an extension key in $component.
A working example from the sandbox demo extension:
namespace Acme\LarsadminRating\Fields;
use Illuminate\Http\Request;
use Maxcxam\Larsadmin\Fields\Field;
class Rating extends Field
{
public string $component = 'acme/rating';
protected int $max = 5;
public function max(int $max): static
{
$this->max = $max;
return $this;
}
protected function defaultAlign(): string
{
return 'center';
}
protected function typeRules(): array
{
return ['integer', 'between:1,'.$this->max];
}
protected function resolveValue(mixed $value): mixed
{
return is_numeric($value) ? (int) $value : null;
}
protected function fillValue(Request $request): mixed
{
$value = $request->input($this->attribute);
return is_numeric($value) ? (int) $value : null;
}
public function jsonSerialize(): array
{
return [...parent::jsonSerialize(), 'max' => $this->max];
}
}
Rating::make('Rating', 'rating')->max(5)->nullable()->sortable(),
Stable members of Field for subclasses:
| Member | Purpose |
|---|---|
public string $component |
key of the Vue component; for extensions always vendor/name |
jsonSerialize(): array |
[...parent::jsonSerialize(), 'max' => …]; subclass keys win over the base ones |
withMeta(array $meta) |
arbitrary props without overriding jsonSerialize() |
$multipart, multipart(bool $multipart = true) |
the field uploads files (see below) |
resolveValue(), formatForDisplay(), fillValue(), fillString(), typeRules(), implicitRules(), acceptsNullInput(), defaultAlign() |
protected hooks |
fill(Request, Model): ?Closure |
filling; the returned closure runs after save() (for related data) |
fillInto(Request, Model, FileJournal): ?Closure |
filling with the save's file journal |
fillAttribute(), resolve(), eagerLoads(), applySort() |
public hooks |
bindTo(Request, string $entity) |
the field learns its entity |
authorizedTo(string $ability): bool |
since 1.6: whether the user may view/update/delete the record the field is resolved for (Fields) |
inAction(): bool |
since 1.6: the field belongs to an action's fields() |
actionValue(Request): mixed |
since 1.6: hook, the field's value for the action's ActionFields (Actions) |
hasAuditValue(), auditValue(Model) |
auditing a field without a column |
__construct(string $name, ?string $attribute = null, ?callable $resolveCallback = null), resolveDefault(), rulesFor(), validationAttributeNames(), isRequired(), sortable() |
hooks you may override (for example, a field without a column refuses sortable()) |
Only the members in this table may be overridden. Overriding anything else (isShownOn(),
isReadonlyFor(), serialize(), …) is not API, and the public API check fails
on it.
Serialized keys of the base field: component, name, attribute, value, displayedAs, sortable,
nullable, readonly, required, help, placeholder, align, wrap, truncate, fullWidth,
multipart (only when true) and inAction (since 1.6, only true, for a field of an action). Minor versions may add keys, so name your own keys so they will not clash
with future ones (for example ratingMax), and ignore unknown keys in your components.
Rules, visibility (showOnIndex(), canSee(), …), readonly(), sensitive() and the other methods work
as for built-in fields (Fields).
Vue: form
The form component renders only the control. The panel renders the label, the "required" marker, the help text and the error message; it also owns the form state, the dirty state, resetting and submitting, so the component never submits the form itself.
| Prop / event | Type | Content |
|---|---|---|
field |
Object | the serialized field: attribute, name, value, readonly, required, placeholder, help, own keys |
modelValue / update:modelValue |
any | the value in the form state; initially field.value. Do not mutate field |
invalid |
Boolean | the field has a validation error |
errors |
Array<string> | messages for attribute and its nested keys attribute.* |
errorsByKey |
Object | the same messages by key: { 'gallery': '…', 'gallery.upload.1': '…' } |
describedBy |
String | null | id of the error or help element, for aria-describedby |
context |
'create' | 'update' |
form context; 'create' in an action modal |
resourceId |
String | Number | null | the record key when updating; in an action, the record key when the action runs on one record, else null |
action |
Object | undefined | since 1.6: { uriKey, entity } in an action modal (Actions); the record form does not pass it |
Requirements: the focusable root element has :id="field.attribute" (the label's for points at it) and
honours field.readonly.
<script setup>
defineProps({
field: { type: Object, required: true },
modelValue: { default: null },
invalid: { type: Boolean, default: false },
errors: { type: Array, default: () => [] },
describedBy: { type: String, default: null },
context: { type: String, default: 'update' },
resourceId: { type: [String, Number], default: null },
})
defineEmits(['update:modelValue'])
</script>
<template>
<input
:id="field.attribute"
type="text"
class="larsadmin-input"
:class="{ 'larsadmin-input-invalid': invalid }"
:value="modelValue ?? ''"
:placeholder="field.placeholder ?? undefined"
:readonly="field.readonly"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy ?? undefined"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
The server validates the value as always (the field's rules), so client-side checks are for convenience only.
Vue: display
The display, index and detail components receive:
| Prop | Content |
|---|---|
field |
the serialized field with value and displayedAs (in the table displayedAs is already cut to truncate) |
context |
'index' | 'detail' |
index and detail are optional: without them display is used in both contexts.
The panel part renders a separate block below the detail fields for a field serialized with panel: true
(for example ->withMeta(['panel' => true])). The component receives field, entity ({ uriKey }) and
record ({ id }), like the built-in relation panel.
Fields that upload files
A field that uploads files turns on multipart:
class Gallery extends Field
{
public string $component = 'acme/gallery';
public bool $multipart = true; // or ->multipart() on an instance
}
The field is serialized with multipart: true, and the form is sent as multipart/form-data (updates as
POST with _method=PUT), as a form with a File field is. The field's value is sent as it is: Inertia
turns objects holding Files into FormData entries. So the component emits, for example,
{ present: 1, upload: [file1, file2] }, and the server reads $request->file("{$attribute}.upload").
The multipart limitation: an empty array is not sent at all. If your field's value is an array, send a
presence flag with it (like present above) or treat a missing key as an empty array (see
Extending).
fileField: true is an internal key that File, Image and their subclasses send: it makes the form turn
the value into the built-in file wire format ({ present, known, keep, upload }). Your own fields must not
set it. An extension field whose value merely looks like that format is sent unchanged.
Errors of nested keys (gallery.upload.1) arrive in errors and errorsByKey; the panel shows the first
one below the field.
A field with multipart: true is not supported in an action's fields() (LogicException, as for
File/Image): an action fills a throw-away model, so the uploads would be orphaned.
Save hooks: fillInto() and FileJournal
Disks are not transactional. So that the files a field wrote do not stay behind after a failed save,
override fillInto() instead of fill() and register the files in the save's journal:
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Maxcxam\Larsadmin\Files\FileJournal;
public function fillInto(Request $request, Model $record, FileJournal $journal): ?Closure
{
$uploads = $request->file("{$this->attribute}.upload", []);
return function (Model $record) use ($uploads, $journal) {
foreach ($uploads as $file) {
$path = $file->store("gallery/{$record->getKey()}", 'public');
// Deleted when the save fails or rolls back.
$journal->created('public', $path);
}
foreach ($record->galleryImages()->whereIn('id', $this->removedIds())->get() as $image) {
$image->delete();
// Deleted only after the outermost commit.
$journal->obsolete('public', $image->path);
}
};
}
Entity::save()callsfillInto()for every field. By defaultfillInto()callsfill(), so existing fields work as before.$journal->created(string $disk, string $path): a file this save wrote. It is deleted when the save fails, also when the error happened in the deferred closure.$journal->obsolete(string $disk, string $path): a file this save made obsolete. It is deleted after the commit.- The rest of
FileJournal(construction,discardCreated(), purging) is internal. - The journal exists in form saves only. Extension routes manage their files themselves.
Full example: the Media field of the add-on
maxcxam/larsadmin-medialibrary (released as 1.0, source in
packages/larsadmin-medialibrary of the monorepo) registers media files, their conversions and responsive images,
and the files of removed media through the journal. It is a complete extension built on this page's API only:
a multipart field, fillInto(), bindTo(), authorizedTo(), audit values, extension routes with findRecord(),
findField() and audited(), and FileSafety.
Audit: hasAuditValue() and auditValue()
The audit log records model column values. A field without a column (media, tags in another table, …) gets audited when it returns its value itself:
public function hasAuditValue(): bool
{
return true;
}
public function auditValue(Model $record): mixed
{
// A fresh query, not a cached relation: the value is read before and after the save.
return $record->tags()->orderBy('name')->pluck('name')->all();
}
auditValue() must return a value json_encode accepts and be cheap: it runs twice on every save with the
audit log enabled. sensitive() masks it as usual. Built-in fields do not override these hooks, so their
audit is unchanged. Changes made in extension routes are audited with Larsadmin::audited().
Binding to the entity: bindTo()
bindTo(Request $request, string $entity) is called once, when the entity builds its fields. A field that
needs its entity (to build extension route URLs or check policies) stores it:
protected ?string $entity = null;
public function bindTo(Request $request, string $entity): void
{
$this->entity = $entity; // class-string<Entity>
}
A field gets the record abilities (view, update, delete) from authorizedTo() (since 1.6) without
policy calls of its own: these are the answers the panel already computed for the row
(Fields).
A field in an action modal
Since 1.6 extension fields work in an action's fields(). The field knows it from inAction() (its
context stays create), and the actionValue(Request $request) hook gives the value for ActionFields:
by default whatever fill() writes to fillAttribute() of a throw-away model. Override it when fill()
writes the value differently; for background actions the value must survive JSON. The form component gets
an extra action prop. Details: Actions: extension fields in the modal.
Cards
A static card
A static card has no server data, only props:
namespace Acme\LarsadminRating\Cards;
use Maxcxam\Larsadmin\Metrics\Card;
class Welcome extends Card
{
public string $component = 'acme/welcome';
public function jsonSerialize(): array
{
return [...parent::jsonSerialize(), 'message' => __('acme-rating::ui.welcome')];
}
}
Welcome::make()->width('1/2')->help('…')->withMeta(['links' => [...]]),
Stable members of Card: $component, $width, $meta, name(), uriKey(), width(), help(),
canSee(), withMeta(array $meta) (adds keys after the base ones; a base key component, uriKey, name,
width, helpText or endpoint throws InvalidArgumentException) and jsonSerialize(). In Vue the card
receives the card prop with component, uriKey, name, width, helpText, endpoint and its own
keys. Cards are registered in a dashboard's or an entity's cards(), like metrics (Dashboards).
<script setup>
import { CardShell } from 'larsadmin'
defineProps({ card: { type: Object, required: true } })
</script>
<template>
<CardShell :card="card">
<p class="px-4 pb-4 text-sm">{{ card.message }}</p>
</CardShell>
</template>
DataCard
Maxcxam\Larsadmin\Metrics\DataCard loads its data from the card's own JSON endpoint. It is the same
endpoint and the same authorization as for metrics: the panel gate, the dashboard's visibility (or the
entity's viewAny for cards above a list), the card's canSee(), the larsadmin-metrics limiter.
namespace Acme\LarsadminRating\Cards;
use App\Larsadmin\Post;
use Maxcxam\Larsadmin\Metrics\CardRequest;
use Maxcxam\Larsadmin\Metrics\DataCard;
class TopRated extends DataCard
{
public string $component = 'acme/top-rated';
public string $width = '1/2';
public function rules(): array
{
return [
'limit' => ['sometimes', 'integer', 'min:1', 'max:20'],
];
}
public function data(CardRequest $request): mixed
{
return [
'items' => $this->entityQuery($request, Post::class, fn ($query) => $query->whereNotNull('rating'))
->orderByDesc('rating')
->limit((int) $request->get('limit', 5))
->get(['id', 'title', 'rating'])
->all(),
];
}
}
| Method | Purpose |
|---|---|
rules(): array |
validation rules of the query parameters; invalid ones answer 422, every other parameter is dropped |
data(CardRequest $request): mixed |
the card's data, json_encode-able |
cacheFor() |
cache lifetime (int seconds, DateInterval, DateTimeInterface); null (the default) disables it |
cacheKeyParts(CardRequest $request): array |
extra parts of the cache key |
entityQuery(CardRequest $request, string $entity, ?Closure $constraint = null): Builder |
protected: a scoped entity query |
The endpoint answers 200 {"data": …}. Do not read the raw $request->request->query() in data(): the
validated parameters are in CardRequest only.
Maxcxam\Larsadmin\Metrics\CardRequest (readonly):
| Member | Content |
|---|---|
$request |
the current Illuminate\Http\Request |
$parameters |
only the validated keys of rules() |
$timezone |
the panel timezone |
$now |
the current moment (CarbonImmutable) in that timezone |
$owner |
dashboard:{key} or entity:{key} |
user() |
the user of the panel guard |
get(string $key, mixed $default = null) |
a validated parameter (dot notation) |
The panel builds the CardRequest (its constructor is internal); in tests use cardData().
DataCard::cardRequest(), resolve() and cacheKey() are internal, and resolve() and cacheKey() are
also final: do not give your card's own methods these names.
entityQuery()
entityQuery() is the data card counterpart of Metric::scoped(): it returns the entity's query limited
by its indexQuery(), after checking viewAny. Without viewAny it throws AuthorizationException and the
endpoint answers 403. $entity takes only a registered entity, as its class or uriKey; anything
else throws InvalidArgumentException. The
$constraint is wrapped in its own where group, so an orWhere inside it cannot escape the scope.
A data() that does not use entityQuery() sees everything its query can read: use a model or a query
builder for global data only, and hide such a card with canSee().
Caching
public function cacheFor(): int
{
return 300;
}
public function cacheKeyParts(CardRequest $request): array
{
return [$request->user()?->current_team_id];
}
The cache key holds the owner (dashboard or entity), the card class and uriKey, the validated parameters,
timezone, locale, guard and the user (class + id), the result of Larsadmin::metricCacheScopeUsing() and
cacheKeyParts(). Store: larsadmin.metrics.cache_store.
On a cache hit data() is not called; the owner's authorization (dashboard or the entity's viewAny),
the card's canSee() and the viewAny of every entity the data was read from through entityQuery()
run again: a user whose right was revoked gets 403 on cached data too. The card knows no other sources
(a model, a query builder, an external API). So:
- when
data()depends on anything besides the user and the parameters (tenant, team, role, session), return it fromcacheKeyParts()or the globalmetricCacheScopeUsing(), or a user is served another scope's data; - access checks inside
data()do not run on cached data: move them tocanSee().
The metricCacheScopeUsing() callback receives the card as its second argument. When that parameter is
typed as Metric only, the callback cannot receive the card, and such a card is never cached (fail
closed). Type the parameter as Metric|DataCard, Card or mixed to cache data cards.
Vue: useCardData() and CardShell
<script setup>
import { computed } from 'vue'
import { Link } from '@inertiajs/vue3'
import { CardShell, useCardData, useI18n, useLarsadmin } from 'larsadmin'
const props = defineProps({ card: { type: Object, required: true } })
const { t } = useI18n()
const { entityUrl } = useLarsadmin()
const { status, data, reload } = useCardData(() => props.card, { limit: 5 })
const items = computed(() => data.value?.items ?? [])
</script>
<template>
<CardShell :card="card" :status="status" :ready="data !== null" :empty="items.length === 0" @retry="reload()">
<ol class="divide-y divide-muted px-4 pb-4 text-sm">
<li v-for="item in items" :key="item.id" class="py-2">
<Link :href="entityUrl('posts', item.id)" class="text-accent-fg hover:underline">{{ item.title }}</Link>
</li>
</ol>
</CardShell>
</template>
useCardData(card, params = {}) sends GET card.endpoint with params in the query and returns
{ status, data, reload }. card and params can be a value, a ref or a getter; a ref or getter params is
watched and the data reloads when it changes. status is idle, loading, error or forbidden; data
keeps the last good value while reloading and becomes null after an error.
CardShell is the card frame the built-in metrics use: the title, the help() tooltip, the skeleton, the
"no access" and "error" states with a "Retry" button (the retry event).
| Prop / slot | Content |
|---|---|
card |
the serialized card (required) |
status |
the status from useCardData() / useJsonResource() |
ready |
there is something to show (while false during the first load, the skeleton is shown) |
empty |
show the "no data" state instead of the slot |
skeletonClass |
class of the skeleton, h-24 by default |
slots default, actions |
the content and header controls (for example a range picker) |
Filters
An extension filter is a regular Filter subclass (Filters) with an extension key in
$component; the server logic (apply(), normalize(), options()) does not change.
class StarsFilter extends Filter
{
public string $component = 'acme/stars';
// apply(), normalize(), options() as in any filter
}
The Vue component receives filter (key, label, component, options for filters with options,
value, default) and modelValue / update:modelValue. Add your own props with the stable hook
serialize(Request $request, mixed $value): array; the result must keep every base key of
parent::serialize():
public function serialize(Request $request, mixed $value): array
{
return [...parent::serialize($request, $value), 'max' => 5];
}
The value goes into the query string, so it is a
scalar or a flat object of strings. The server normalizes it with normalize().
Tools: sidebar pages
A tool is a page in the sidebar ("Tools" section) with its own Vue component.
namespace Acme\LarsadminRating\Tools;
use Illuminate\Http\Request;
use Maxcxam\Larsadmin\Tools\Tool;
class Backups extends Tool
{
public string $component = 'acme/backups';
public function icon(): ?string
{
return 'database';
}
public function props(Request $request): array
{
return ['lastRun' => cache('backups.last_run')];
}
}
// boot() of the extension's or the application's provider
Larsadmin::tools([
Backups::class,
// Backups::make()->canSee(fn (Request $request) => $request->user()->isAdmin()),
]);
| Member | Purpose |
|---|---|
$component |
key of the extension component, always vendor/name |
name() |
menu label and page title; by default __() of the class name |
uriKey() |
key in the URL; by default the kebab-cased class name |
icon() |
menu icon from Tool::ICONS; null or an unknown name gives the default icon |
canSee(Closure) / authorizedToSee(Request) |
visibility; without canSee() everyone past the panel gate sees the tool |
props(Request $request): array |
page props (JSON) |
make(...$args) |
create an instance |
The page: GET {path}/tools/{uriKey}. An unknown or invisible tool answers 404 (not 403, so its existence
is not revealed). Larsadmin::tools() throws InvalidArgumentException for anything that is not a Tool,
for a key without /, and for two classes with the same uriKey. There are also
Larsadmin::registeredTools() and Larsadmin::toolForKey($key) (visibility is not checked).
The Vue component receives tool (name, uriKey, component) and props (from props()); the panel
renders it in its layout under the tool.name title. Data that is not in props() comes from
extension routes.
<script setup>
defineProps({
tool: { type: Object, required: true },
props: { type: Object, default: () => ({}) },
})
</script>
<template>
<div class="larsadmin-box p-4">{{ props.lastRun }}</div>
</template>
Icons (Tool::ICONS, in JS Icon.NAMES): alert, arrow-down, arrow-up, bell, check,
chevron-down, chevron-left, chevron-right, dash, database, device-desktop, download, eye,
file, globe, history, home, kebab, lock, menu, moon, pencil, plus, search,
sign-out, sun, trash, upload, x.
Extension routes
Register your own JSON routes with Larsadmin::routes() in the provider:
use Acme\LarsadminRating\Http\ResetRatingController;
use Illuminate\Support\Facades\Route;
use Maxcxam\Larsadmin\Larsadmin;
Larsadmin::routes(function () {
Route::post('acme-rating/{entity}/{id}/reset', ResetRatingController::class)->name('acme-rating.reset');
});
The routes go into the {path}/ext group, named larsadmin.ext.…, behind the panel middleware
(larsadmin.middleware + larsadmin.auth_middleware): POST /admin/ext/acme-rating/posts/1/reset named
larsadmin.ext.acme-rating.reset. Mutating routes get CSRF protection from the web group. The same
Closure object is registered once. With route:cache the closures run while caching, like any package
routes.
routes() guarantees only the panel gate. The route does the rest of the authorization (policy,
scoping, field visibility) itself, with the helpers below.
namespace Acme\LarsadminRating\Http;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Maxcxam\Larsadmin\Larsadmin;
class ResetRatingController
{
public function __invoke(Request $request, string $entity, string $id): JsonResponse
{
$record = Larsadmin::findRecord($request, $entity, $id, 'update');
$field = Larsadmin::findField($request, $entity, $record, 'rating');
Larsadmin::audited($request, $entity, $record, function (Model $record) use ($field) {
$record->forceFill([$field->attribute => null])->save();
});
return response()->json(['ok' => true]);
}
}
From Vue such a route is called through the SDK:
import { useJsonRequest, useLarsadmin } from 'larsadmin'
const { url } = useLarsadmin()
const { send } = useJsonRequest()
const { ok, status, data } = await send('POST', url('ext', 'acme-rating', 'posts', 1, 'reset'))
findRecord()
Larsadmin::findRecord(Request $request, string $entity, int|string $id, string $ability = 'view'): Model
Finds a record the way the panel does: the entity's viewAny (403), the detailQuery() scope (404 for
rows outside it), then the policy ability on the record (403). $entity is the entity's uriKey; an
unknown one is a 404. $ability: view, update or delete (anything else throws
InvalidArgumentException). Call it in every route that works with a record.
findField()
Larsadmin::findField(Request $request, string $entity, Model $record, string $attribute, string $context = Field::UPDATE): Field
Returns the entity's field for the record as the panel sees it: bound (bindTo()), shown in $context to
this user for this record (404 otherwise) and, for create/update, not readonly (403 otherwise). It
returns a clone, so you may resolve() it. Call it after findRecord(). $context: Field::INDEX,
DETAIL, CREATE or UPDATE. Since 1.6 the field's authorizedTo() answers for this record (always
false in create); the policy is asked only when the field calls authorizedTo().
audited()
Larsadmin::audited(Request $request, string $entity, Model $record, Closure $change): mixed
Runs a change to a record outside the form and records it in the audit log as updated: a snapshot of the
entity's fields before, $change($record), a snapshot after, the diff, masking as usual. All in one
transaction; returns the closure's result. There is no entry when nothing changed, the audit log is off or
the entity is not audited. Fields with hasAuditValue() are part of the diff too. A record that is not a
model of that entity throws InvalidArgumentException.
Translations
The extension has lang/{locale}/ui.php:
// lang/en/ui.php
return [
'stars' => ':count star|:count stars',
'not_rated' => 'Not rated',
];
The provider registers the files the standard way and exposes the group to JavaScript:
$this->loadTranslationsFrom(__DIR__.'/../lang', 'acme-rating');
Larsadmin::extension('acme/larsadmin-rating', __DIR__.'/../dist')
->translations('acme-rating'); // the 'ui' group by default
const { t, tc, locale } = useI18n()
t('acme-rating::not_rated')
tc('acme-rating::stars', 3) // "3 stars"
In JavaScript a key looks like namespace::key (the group is not written); nested arrays give
namespace::group.key. In PHP (class names, rules) translations are the usual ones:
__('acme-rating::ui.not_rated'). Built-in panel keys never contain ::, so there are no collisions. The
panel locales are shared; when a locale has no translation, Laravel's fallback locale applies, and without it
t() returns the key. The host can override the translations in lang/vendor/acme-rating (standard
Laravel). The skeleton ships uk and en.
Styles and theming
An extension builds its own Tailwind CSS v4 utilities with the panel's theme:
/* resources/css/extension.css */
@import "tailwindcss/utilities" layer(utilities.extensions) source(none);
@import "larsadmin-kit/theme.css";
@source "../js";
layer(utilities.extensions)puts the utilities in a sublayer of the panel'sutilitieslayer: they still beat the panel's base and component classes, but never the panel's own utilities. A plain@import "tailwindcss/utilities";emits unlayered CSS, and its.hidden { display: none }then beats the panel's layeredmd:block, hiding parts of panel pages (before 1.6.5 the skeleton did this).source(none)with@source "../js": only the classes used by the extension's components are generated, not words inREADME.mdor PHP files that happen to look like classes.- Trade-off: an extension utility loses to a panel utility of the same name.
kit/theme.css imports Tailwind's default theme as a reference: classes such as p-4, size-6,
text-sm, rounded, md:grid-cols-2 are generated, but the theme variables are not duplicated; they come
from the panel. Colours are mapped onto the --la-* tokens, so they follow the light and dark themes:
| Utility | Token |
|---|---|
text-fg, text-fg-muted, text-fg-subtle, text-fg-on-emphasis |
--la-fg-* |
bg-canvas, bg-canvas-subtle, bg-canvas-inset |
--la-canvas-* |
border-default, border-muted |
--la-border-* |
*-accent-fg, *-accent-emphasis, *-accent-subtle |
--la-accent-* |
*-success-*, *-danger-* (fg, emphasis, subtle) |
--la-success-*, --la-danger-* |
bg-neutral-muted, bg-header, text-header-fg, bg-overlay, shadow-overlay |
other tokens |
The stable CSS contract: the --la-* tokens (Theming) and the classes
larsadmin-input, larsadmin-input-invalid, larsadmin-checkbox, larsadmin-btn,
larsadmin-btn-primary, larsadmin-btn-secondary, larsadmin-btn-danger, larsadmin-btn-sm,
larsadmin-box, larsadmin-box-header, larsadmin-label, larsadmin-label-success,
larsadmin-label-accent, larsadmin-counter. The utility classes in the panel's app.css are not a
contract: their set depends on the panel's code, so an extension builds its own.
For charts that pick colours themselves, useTheme().resolved returns 'light' or 'dark' (the user's
choice or the OS setting); the chart palette is --la-chart-1 … --la-chart-8.
The public JS API
The larsadmin module
Every export below is part of the 1.x SemVer contract (minors only add):
| Export | Description |
|---|---|
apiVersion |
the string '1.6' (Larsadmin::API_VERSION) |
defineExtension(options) |
the extension definition (above) |
useI18n() |
{ t, tc, locale }; extension keys t('acme-rating::key') |
useLarsadmin() |
{ url(...segments), entityUrl(uriKey, ...segments) } under the panel path; segments are encoded, . and .. refused |
useJsonRequest() |
{ send(method, url, body?, { background }) } → { ok, status, data, expired? }; X-XSRF-TOKEN for mutations, to same-origin URLs only; on 401/419 a GET or background: true request returns expired: true, any other reloads the page |
useJsonResource() |
{ status, load(url, { background }), cancel }; cancels a stale request; status: idle, loading, error, forbidden |
useCardData(card, params?) |
{ status, data, reload } for a DataCard |
useToasts() |
{ push({ message, type, title?, timeout? }), dismiss(id) }; type: success, info, warning, error; links are not supported |
useTheme() |
{ resolved }: 'light' | 'dark' |
useNumberFormat() |
{ format(value, options) }: Intl.NumberFormat options plus prefix/suffix, in the panel locale; null and NaN give "—" |
buildUrl(base, segments), withParams(url, params) |
pure URL helpers |
Icon |
<Icon name="pencil" />; names in Icon.NAMES (the same as Tool::ICONS); decorative, the control provides the label |
CardShell |
the card frame (above) |
ConfirmDialog |
an accessible confirmation dialog |
The import map specifiers vue (the Vue 3 runtime, ^3.5) and @inertiajs/vue3 (Inertia v3, the panel's
own instance) are stable too: usePage(), Link and router in an extension work with the panel's page.
Of the shared props only larsadmin.locale, larsadmin.path and user are a contract.
Internal (not a contract): everything else in the panel's resources/js/**, including registerField,
registerCard, registerFilter, Combobox, EntityForm, FieldBlocks and the rest of the shared props.
Importing panel files directly (vendor/maxcxam/larsadmin/resources/js/...) does not work: they are not in
the Composer archive.
The author kit (vendor/maxcxam/larsadmin/kit)
| File | Exports |
|---|---|
kit/vite.js |
larsadmin(options) (and default): the Vite preset |
kit/testing.js |
withPanel(), mountField(), mountDisplay(), mountCard() |
kit/theme.css |
the Tailwind theme for extensions |
kit/sdk.js |
the larsadmin module built for vitest (the preset wires it in) |
Overriding built-in components
An extension can replace the UI of a few built-in components with an explicit override: true:
export default defineExtension({
name: 'acme/larsadmin-ui',
requires: '^1.5',
fields: {
// the form only; index and detail stay built-in
'boolean-field': { form: BooleanSwitch, override: true },
},
cards: {
'value-metric': { component: BigNumberCard, override: true },
},
filters: {
'date-range-filter': { component: RangePicker, override: true },
},
})
For fields the parts (form, display, index, detail) are overridden separately. Cards and filters use
the object form { component, override: true }.
What can be overridden
| Kind | Keys | Parts |
|---|---|---|
| Fields | text-field, password-field, textarea-field, number-field, boolean-field, select-field, date-field, datetime-field |
form, display, index, detail |
| Filters | select-filter, boolean-filter, date-range-filter |
component |
| Cards | value-metric, trend-metric, partition-metric, progress-metric |
component |
The other built-in keys (id-field, file-field, image-field, belongs-to-field, has-many-field,
belongs-to-many-field) and the panel part cannot be overridden (console.warn): their formats and
endpoints are internal. A built-in key without override: true is rejected with console.warn too. Only
one extension may override a part of a key: the first wins, later ones are rejected with console.error.
The contract
An override receives the same props and events as the built-in component, plus the general extension contract. All of it is stable in 1.x:
- fields, form:
field,modelValue/update:modelValue,invalidand the form contract (errors,errorsByKey,describedBy,context,resourceId); display:field,context. fieldkeys: the base ones (above) plustext-field/password-fieldinputType;textarea-fieldrows;number-fieldmin,max,step;select-fieldoptions([{ value, label }]).- the value shape in
update:modelValueis the built-in one: a string (text,password,textarea), a number (number),true/false(boolean), an option value (select),YYYY-MM-DD(date-field),YYYY-MM-DDTHH:mm:ss(datetime-field, asdatetime-local). - filters:
filter,modelValue/update:modelValue; the value is'1','0'ornullforboolean-filter,{ from, to }(YYYY-MM-DDstrings) ornullfordate-range-filter, an option value forselect-filter. - cards:
cardwith theCardkeys plusranges([{ value, label }]) anddefaultRange. The endpoint answers{ range, result }; the shape ofresultis described in Metrics.
The internal markup, CSS classes and child components of built-in components are not a contract.
Security and fallback
- An override changes only the client-side rendering. The
componentkey on the server, the rules,fill(),canSee(), policies, scoping and the value format the server accepts do not change: the server validates input as always. - While the extension is loading, the built-in component renders (no skeleton).
- When the extension did not load, is incompatible or its
setupthrew, the override does not apply. - When an override component throws while rendering, the panel switches that key (part) back to the built-in component for the rest of the page session and logs the error. The field's value is sent, because the built-in component handles it.
- With
APP_DEBUG=trueaconsole.infolists "who overrode what" after loading.
Unavailable and crashed extensions
Extension failures are isolated; the panel keeps working:
- The loader imports all extensions in parallel and waits for them for up to 5 seconds, then mounts the
panel; late extensions render when they arrive. They register in the order of the server's list (the
order of
Larsadmin::extension()), not in the order their imports finish, so the winner of a key conflict does not depend on the network. While any is still loading, an extension component shows a "Loading extension…" skeleton. - An extension is not loaded (
console.errorwith the reason) when the import fails or does not finish within 30 seconds, the default export is notdefineExtension(), thenamediffers from the server's,requiresis incompatible orsetup()threw. ExtensionBoundaryswitches an extension component off for the rest of the page session only when it cannot render: an error in render,setup, lifecycle hooks or an async component loader. Errors in event handlers, watchers and async code are only logged with the extension name (plus a toast withAPP_DEBUG=true): the component stays, and the user's input is kept and sent.
What the user sees in place of an unavailable vendor/name component:
| Where | Placeholder |
|---|---|
| form | a box "Unavailable: the extension is not loaded" with the hint "This field will not be changed when you save." |
| index, detail | "—" with an "Unavailable…" tooltip |
| card, tool, panel | a block with the name and "Unavailable…" |
| filter | the filter label and "Unavailable…" |
Fail closed in forms. A field whose extension component is unavailable (not loaded, incompatible,
crashed) is not sent: the server keeps the stored value, and on create required applies. A key without
/ that the panel does not know still renders as a text input (1.4 compatibility).
Debug toast. Only with APP_DEBUG=true the panel user also sees a toast "Extension X was not loaded"
or "Extension X requires API ^1.7, the panel provides 1.6". In production there are only the console and the
placeholders.
CSP and nonces
Panel and extension files are on the same origin, so script-src 'self' and style-src 'self' cover them.
The panel's inline scripts (theme detection and the import map), the app.js tag and the modulepreload
tags get a nonce attribute when the application turns on Laravel's standard Vite::useCspNonce():
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;
class ContentSecurityPolicy
{
public function handle(Request $request, Closure $next): Response
{
Vite::useCspNonce();
$response = $next($request);
$response->headers->set('Content-Security-Policy', "script-src 'self' 'nonce-".Vite::cspNonce()."'");
return $response;
}
}
The middleware must run before the panel page renders (for example in the web group or in
larsadmin.middleware). Without a nonce a strict CSP blocks the import map: the panel works and extension
fields show "Unavailable". 'strict-dynamic' is compatible: modules imported by the trusted app.js are
allowed. SRI is not used (same origin, files of the same deployment). Without registered extensions there is
no import map on the page.
Versioning and compatibility
Larsadmin::API_VERSION(PHP) andapiVersion(JS) are'1.6': theMAJOR.MINORof the version that last extended the public API. A minor that does not change the API does not raise it.- Composer guarantees PHP compatibility (
"maxcxam/larsadmin": "^1.6"in the extension'scomposer.json;"^1.5"for an extension without the 1.6 API).requiresin JavaScript catches a staledist/. - Frozen for 1.x: Vue 3 (≥ 3.5) and Inertia v3, the import map specifiers, the component contracts on
this page, the exports of the
larsadminmodule and of the kit, the CSS contract, the PHP members listed as stable. Moving to Vue 4 or Inertia v4 can happen only in 2.0. - Minors only add: new component props, new SDK exports, new serialized keys. Extensions must ignore unknown props and keys.
- Deprecations: a member gets
@deprecated(PHP) or a one-timeconsole.warnwithAPP_DEBUG=true(JS), stays until the end of the major and is removed in the next one; the CHANGELOG has a "Deprecated" section with the replacement.
Stable PHP classes and members:
| Class | Stable members |
|---|---|
Larsadmin |
API_VERSION, extension(), registeredExtensions(), extensionFor(), routes(), findRecord(), findField(), audited(), tools(), registeredTools(), toolForKey(), assetsVersion() |
Extensions\Extension |
$name, $path, script(), style(), translations(), version(), url() |
Fields\Field |
see the field's PHP class |
Files\FileJournal |
created(), obsolete() |
Files\FileSafety |
since 1.6: rule(), blockedExtension(), storedExtension(), sanitizeName(), asciiName(), isRasterImage(), response() (Files and images) |
Metrics\Card |
$component, $width, $meta, name(), uriKey(), width(), help(), canSee(), withMeta(), jsonSerialize() |
Metrics\DataCard |
rules(), data(), cacheFor(), cacheKeyParts(), entityQuery() |
Metrics\CardRequest |
$request, $parameters, $timezone, $now, $owner, user(), get() |
Tools\Tool |
$component, ICONS, make(), name(), uriKey(), icon(), canSee(), authorizedToSee(), props() |
Filters\Filter |
$component, make(), apply(), name(), key(), options(), default(), normalize(), canSee(), serialize() |
Testing\InteractsWithLarsadmin |
serializeField(), fillField(), cardData(), assertExtensionRegistered(), extensionAssetResponse() |
An extension may refer to the panel's other documented classes (entities, built-in fields, filters,
metrics, actions, dashboards, Layout\Panel, Layout\Tabs, events, LarsadminNotification) just as an
application does. Internal members are not API, even when they are public in PHP: Entity::fieldPrototypes(),
isShownOn(), isReadonlyFor() (use findField() instead), the constructor and helper methods of
Extension, the CardRequest constructor, DataCard::cardRequest()/resolve()/cacheKey(),
Extension::NAME_PATTERN, Tool::COMPONENT_PATTERN, Tool::$canSee, Field::withAbilities()/forAction(),
Files\FileTypes, Files\FileResponse and anything marked @internal. Since 1.6 these are marked internal
too: Larsadmin::locale(), locales(), navigation(), searchOrder(), modelUsesScout(),
dashboardForKey(), home(), dashboardNavigation(), metricCacheScope() and timezone().
Checking the public API
The full list of the stable surface is kit/public-api.json, and kit/check-public-api.mjs checks it
(Node ≥ 20, no dependencies). Both ship in the panel's Composer archive, so you check an extension from its
directory (paths are relative to the current directory):
node vendor/maxcxam/larsadmin/kit/check-public-api.mjs .
The script checks two things:
- the extension uses only the stable surface. JS/Vue/CSS imports only
vue,@inertiajs/vue3, third-party packages, its own files, thelarsadminexports and the kit files (no panel files fromresources/js, nolarsadmin/...subpaths); PHP refers only to listedMaxcxam\Larsadmin\…classes and calls no internal members; - the extension overrides only stable hooks. A class extending
Field,Filter,Card/DataCardorTool(directly or through its own classes) may override only the stable members of its base class.
The extension's tests/ directory is not checked: tests may refer to internal classes, for example in
parity tests against code copied from the panel. In the panel repository npm run check:api checks the
surface itself: nothing in the snapshot disappeared and nothing was added without updating the snapshot.
Testing
Pest (PHP)
The skeleton has a tests/TestCase.php on Orchestra Testbench (in-memory SQLite, Laravel's migrations, a
fixture user model, an open panel gate) and tests/Pest.php:
pest()->extend(TestCase::class)->use(InteractsWithLarsadmin::class)->in(__DIR__);
The Maxcxam\Larsadmin\Testing\InteractsWithLarsadmin trait (also for PHPUnit) uses only the public API:
| Method | What it does |
|---|---|
serializeField(Field $field, ?Model $record = null, string $context = Field::DETAIL, ?string $entity = null, array $abilities = []): array |
serializes the field as the panel does for a page; with $entity (a class or uriKey) calls bindTo() first; $abilities (since 1.6, e.g. ['view' => true]) sets the answers of authorizedTo() for the record (ignored without a record and in create) |
fillField(Field $field, Model $record, array $input, array $files = [], string $context = Field::UPDATE): Model |
validates the input with the field's rules (ValidationException), calls fillInto() with a file journal as the panel does, saves and runs the deferred closure in a transaction; on failure deletes the files registered with created() |
cardData(DataCard $card, array $query = [], ?Authenticatable $user = null, string $owner = 'dashboard:test'): mixed |
canSee() (AuthorizationException), rules() (ValidationException), data() |
assertExtensionRegistered(string $name) |
the extension is registered |
extensionAssetResponse(string $name, string $file): TestResponse |
GET of the file's versioned URL as the acting user |
it('serializes the rating', function () {
$post = new Post(['rating' => 4]);
expect($this->serializeField(Rating::make('Rating', 'rating')->max(5), $post))
->toMatchArray(['component' => 'acme/rating', 'value' => 4, 'max' => 5]);
});
it('rejects invalid card parameters', function () {
$this->cardData(TopRated::make(), ['limit' => 'many']);
})->throws(ValidationException::class);
Larsadmin::flushState()resets all static state, including the extensions, routes and tools that providers registered at boot. If your tests call it, register the extension again insetUp()afterflushState(), or do not call it in extension tests.- The skeleton's HTTP test (
tests/HttpTest.php) checks the card endpoint and servingdist/extension.js(skipped untilnpm run build).
vitest (Vue)
In test mode the preset points larsadmin at kit/sdk.js, turns on jsdom and excludes vendor/** and
dist/**. The kit/testing.js helpers (through the skeleton's larsadmin-kit alias) mount components with
the panel contract without an Inertia app:
import { describe, expect, it } from 'vitest'
import { mountDisplay, mountField } from 'larsadmin-kit/testing.js'
import RatingInput from './RatingInput.vue'
import RatingDisplay from './RatingDisplay.vue'
const translations = { 'acme-rating': { stars: ':count star|:count stars' } }
describe('RatingInput', () => {
it('emits the chosen number', async () => {
const wrapper = mountField(RatingInput, { field: { attribute: 'rating', value: 2, max: 5 }, panel: { translations } })
await wrapper.findAll('input[type="radio"]')[3].setValue(true)
expect(wrapper.emitted('update:modelValue').at(-1)).toEqual([4])
})
})
describe('RatingDisplay', () => {
it('renders on the index', () => {
const wrapper = mountDisplay(RatingDisplay, { field: { attribute: 'rating', value: 4, max: 5 }, context: 'index', panel: { translations } })
expect(wrapper.text()).toContain('4')
})
})
| Helper | What it mounts |
|---|---|
withPanel({ locale, translations, path, props }) |
mount options that give the SDK a panel page (useI18n, useLarsadmin, …) |
mountField(Component, { field, modelValue, errors, errorsByKey, action, context, resourceId, panel, options }) |
a form component with the contract; v-model is wired; errors turns on invalid and describedBy {attribute}-error; action: { uriKey, entity } (since 1.6) mounts the field as in an action modal (inAction: true, context defaults to 'create') |
mountDisplay(Component, { field, context, panel, options }) |
a display component; displayedAs defaults to String(value) |
mountCard(Component, { card, panel, options }) |
a card with the serialized Card keys |
options is passed to @vue/test-utils' mount().
Testing the application's entities, policies, actions, filters and metrics is covered in Testing.
Building and publishing
npm run build # dist/extension.js, extension.css, chunks/, .vite/manifest.json
npm run dev # rebuilds after every change (vite build --watch)
npm test # vitest
vendor/bin/pest # after composer install in the package directory
The preset larsadmin({ entry = 'resources/js/extension.js', outDir = 'dist' }) builds the ES library
dist/extension.js, all styles into one dist/extension.css, chunks into dist/chunks/ and a manifest for
the version, and keeps vue, @inertiajs/vue3 and larsadmin external. A guard stops the build when
the bundle contains its own copy of Vue, @vue/*, Inertia or the SDK (a second Vue is the sneakiest bug: it
silently breaks reactivity and usePage()). Import them only as vue, @inertiajs/vue3 and larsadmin,
without deep paths or aliases.
Publishing:
- Commit
dist/afternpm run build. - The package's
composer.jsonrequires"maxcxam/larsadmin": "^1.6"and registers the provider inextra.laravel.providers(the skeleton does both). - Tag a version and publish the package (Packagist or a private repository).
The host installs the package with composer require and does nothing else. After composer update the
file version changes and open tabs reload on their next navigation.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| The build fails with "contains its own copy of Vue, Inertia or the larsadmin SDK" | Vue/Inertia imported through a deep path or alias, or a dependency pulls its own Vue; import only vue, @inertiajs/vue3, larsadmin |
| Fields show "Unavailable", nothing about the extension in the console | the CSP blocks the import map: turn on Vite::useCspNonce() (CSP) |
| "Extension "…" was not loaded (the module is named …)" | the name in defineExtension() differs from Larsadmin::extension() |
| "… requires ^1.7, the panel API is 1.6" | requires is above the panel API: update the panel or lower requires |
| The extension's styles do not apply | no dist/extension.css (the CSS is not imported in extension.js) or the classes were not generated (@source "../js") |
| A key is rejected with "use override: true" | a key without / matched a built-in one; use vendor/name, or override: true for an allowed key |
composer require acme/…:^1.6 finds no compatible panel |
the panel comes from a branch or path repository: see Quick start |
| The extension disappears in tests | the test called Larsadmin::flushState(): register it again |
check-public-api.mjs: "… overrides …, which is not a stable override hook" |
the class overrides an unstable member of its base class; move the logic into a stable hook |
HTTP endpoints
| Method | URI (under {path}) |
Name (larsadmin.…) |
Responses |
|---|---|---|---|
| GET | extensions/{vendor}/{package}/{version}/{file} |
extensions.asset |
200 file, 304, 404 (not registered, traversal, dotfile, type) |
| GET | api/dashboards/{dashboard}/metrics/{card} |
api.dashboard-metric |
DataCard: 200 {"data": …}; 404 invisible dashboard or card, a static card; 422 {"message", "errors"} |
| GET | api/entities/{entity}/metrics/{card} |
api.entity-metric |
as above; 403 without viewAny |
| GET | tools/{tool} |
tools.show |
200 Inertia page Tool; 404 unknown or invisible |
| any | ext/… |
ext.… |
defined by the extension |
Serving extension files: only to users who pass the panel gate (limiter larsadmin-files); only files
inside the realpath of the registered directory; dotfiles (including .vite/manifest.json) are closed;
allowed types js, mjs, css, json, woff2, woff, ttf, png, jpg, jpeg, gif, webp,
avif, ico, svg (with Content-Security-Policy: sandbox) and map (only with
LARSADMIN_EXTENSION_SOURCE_MAPS=true). Headers: X-Content-Type-Options: nosniff,
Cross-Origin-Resource-Policy: same-origin, ETag and Last-Modified. The current version is cached as
private, max-age={extensions.max_age}, immutable, an old one is served with no-cache.
Config (config/larsadmin.php):
| Key | Env | Default | Description |
|---|---|---|---|
extensions.source_maps |
LARSADMIN_EXTENSION_SOURCE_MAPS |
false |
serve the extensions' *.map files (debugging only) |
extensions.max_age |
31536000 |
browser cache lifetime of versioned files, in seconds |
Shared props (only for users past the gate): larsadmin.extensions ([{ name, script, style }]),
larsadmin.extensionTranslations, larsadmin.debug, tools. Without registered extensions and tools these
props are absent, and the Inertia version is the same as in 1.4.
Limitations
- The first load is about 27 KB gzip heavier than in 1.4, also without extensions: Vue and Inertia live
in shared chunks that extensions import through the import map, and they are not tree-shaken. After that
the browser caches the files. There are no extra sequential round trips: the root view emits
<link rel="modulepreload">tags (with thenonce) for the static imports ofapp.js, read fromdist/.vite/manifest.json(no tags without a manifest), so afterapp.jsthe browser loads the vue, inertia and shared chunks in parallel. - Fields that upload files (
File,Image,multipart: true) are not supported in action modals. Larsadmin::flushState()removes the extensions providers registered at boot (see Testing).FileJournaldeletes files, not directories: after a discarded upload empty directories can remain (for example spatie/laravel-medialibrary media directories).larsadmin:extensionalways writes the package toPATH/VENDOR/PACKAGE(--path=sandbox/extensionsgivessandbox/extensions/acme/larsadmin-rating).- Extension routes manage their files themselves: they have no file journal.
- Extensions without a build step can only use render functions (
h()), no string templates. - No remote scripts (CDN), no npm package of the kit, no TypeScript types.
- No custom action modals, layout slots, entity pages, cards on the detail page or dependent fields. Cards only read data; changes are made with actions or extension routes.
- Links in extension toasts are not supported.
Next: AI assistants.