Extending
Extension API
The browser API of @lathephp/core and @lathephp/ui that fields, filters, widgets, pages and plugins build on.
Every extension of the panel runs in the browser and talks to the panel through two packages. @lathephp/core is the API: it registers your components and gives them requests, navigation, toasts, shortcuts, hooks and types. @lathephp/ui is the UI kit that the panel itself uses.
import { field, plugin, request, toast } from '@lathephp/core';
import { Button, Card } from '@lathephp/ui';
Both are plain ES modules. The panel's import map points them at the panel's own files, so there is no global object and no second copy of React. In an app, @lathephp/vite-plugin builds your code against them. In a distributed plugin, the build leaves them out and the panel supplies them. See Frontend and Plugin distribution.
apiVersion exports the semantic version of the API. Everything the packages export is public API and follows semver. Anything not exported is internal.
Register components
Each call binds a key to a React component. The key must match the key the PHP class sends:
| Call | Registers | The key matches |
|---|---|---|
field(key, renderers) |
A custom field | The field's $component |
filter(key, component) |
A custom filter | The filter's component() |
widget(key, component) |
A custom widget | The widget's component key |
plugin(uriKey, component) |
A plugin page at /lathe/plugins/{uriKey} |
The plugin's uriKey() |
page(uriKey, component) |
A page at /lathe/pages/{uriKey} |
The page's uriKey() |
resourcePlugin(key, component) |
A block on a detail page | The resource plugin's component() |
actionModal(name, component) |
The modal of an action result | The name given to ActionResult::modal() |
A key that exists is replaced: the last registration wins. A built-in field keeps its own renderers for its key.
A field takes an object with the renderers it needs: form, index and detail. A bare component is the form input:
import { field, filter, plugin, resourcePlugin } from '@lathephp/core';
field('star-rating', { form: StarRatingForm, index: StarRatingIndex, detail: StarRatingDetail });
filter('price-tier-filter', PriceTier);
plugin('insights', Insights);
resourcePlugin('activity-timeline', ActivityTimeline);
Every call also takes a lazy component, so the code loads when it is first drawn:
import { lazy } from 'react';
page('reports', lazy(() => import('./pages/Reports')));
The props each component receives:
| Registered with | Props type | What it carries |
|---|---|---|
field form |
FieldProps |
field, value, onChange, id, describedBy, invalid, disabled, values |
field index |
IndexFieldProps |
field, cell (value, display) |
field detail |
DetailFieldProps |
field, with the record's value and display on it |
filter |
FilterProps |
filter, value, onChange |
widget |
WidgetProps |
widget, value |
plugin |
PluginPageProps |
pluginKey, basePath, path |
page |
PageProps |
pageKey, basePath, path |
resourcePlugin |
ResourcePluginProps |
resourceName, resourceId |
actionModal |
ActionModalProps |
data, close |
path is the part of the URL after the plugin or page key. It is an empty string when there is none.
Render hooks
A render hook adds a component at a fixed position of the panel, without replacing anything. Every hook for a position renders, in the order they were added:
import { renderHook } from '@lathephp/core';
renderHook('detail:header-actions', SyncButton, { resources: ['users'] });
There are ten positions:
| Position | Where it renders |
|---|---|
head |
The document head |
header:end |
The end of the top bar |
sidebar:before |
Above the sidebar navigation |
sidebar:after |
Below the sidebar navigation |
page:before |
Above the page content |
page:after |
Below the page content |
footer:before |
Above the footer |
index:header-actions |
The action area of a Resource index header |
detail:header-actions |
The action area of a Resource detail header |
auth:after-form |
Beneath the sign-in form |
Pass { resources: [...] } with Resource uriKeys to limit a hook to those Resources. A hook with no resources option renders everywhere. A hook that is limited does not render on a position that has no Resource.
The component receives RenderHookProps: resource (the uriKey) on Resource pages and record on detail pages.
import { toast } from '@lathephp/core';
import type { RenderHookProps } from '@lathephp/core';
import { Button } from '@lathephp/ui';
export function SyncButton({ resource }: RenderHookProps) {
return (
<Button size="sm" variant="outline" onClick={() => toast.info(`Syncing ${resource ?? ''}`)}>
Sync
</Button>
);
}
Each hook sits inside an error boundary. A hook that throws renders nothing and logs the error, and the rest of the page works.
Replace a part of the panel
registry.replace(key, component) swaps a built-in part for your component:
import { registry } from '@lathephp/core';
import type { SlotProps } from '@lathephp/core';
function Footer({ slotVersion }: SlotProps) {
return <footer>Version {slotVersion}</footer>;
}
registry.replace('shell.footer', Footer);
The keys are shell.header, shell.sidebar, shell.footer, auth.login, and a kind plus a component key written as kind:key. The kinds are field.index, field.detail, field.form, filter, widget and action-modal, for example field.index:badge-field. The replacement gets the props of the part it replaces, plus slotVersion, a number that changes only in a major release. An unknown key throws in development and logs a warning in production.
Make requests
request() is the panel's HTTP client. It sends the CSRF token, asks for JSON, answers null for a 204, and throws an ApiError for a failed response:
import { request } from '@lathephp/core';
const summary = await request<Summary>('/lathe/api/plugins/insights/summary');
Its second argument takes method, body (sent as JSON), headers, signal, responseType ('blob' for a download) and toastOnError. Build API addresses from useLathe().apiPath, so a changed panel path does not break them.
await request(`${apiPath}/plugins/insights/refresh`, { method: 'POST', body: { force: true } });
csrfHeaders() returns the headers if you need to call fetch yourself.
Navigate
visit(path) goes to a panel path through Inertia and adds the panel prefix. url(path) builds the address without going there:
import { url, visit } from '@lathephp/core';
visit('resources/users'); // /lathe/resources/users
const href = url('pages/reports');
router is the panel's Inertia router with visit(url, options) and reload(options) for the cases that need Inertia's options. It exists once the panel has started, so call it from a component or from a booting() callback.
Read data from PHP
Values from Lathe::provideToScript() are read with config(key):
import { config } from '@lathephp/core';
const viewer = config('insightsViewer') as string;
useLathe() gives a component the panel's basePath, its apiPath, config and the signed-in auth:
const { apiPath, auth } = useLathe();
Show toasts
import { toast } from '@lathephp/core';
toast.success('Summary refreshed.');
toast.error('Could not load the summary.');
toast.info('Syncing in the background.');
toast.warning('Some records were skipped.');
Each takes a message and an optional second argument with duration in milliseconds (4,000 by default), an action ({ label, onClick }, such as Retry) and links ({ label, href, newTab }). At most three toasts show at once.
The event bus
Components talk to each other over a global bus. on returns the function that removes the listener:
import { emit, on, toast } from '@lathephp/core';
import { useEffect } from 'react';
useEffect(() => on('gallery:ping', () => toast.info('Ping received')), []);
emit('gallery:ping');
once(event, listener) removes itself after one call, and off(event, listener) removes a listener you hold. The panel itself emits actions:ran after an action runs, with { action }, and filters:changed when the index filters change, with { resource, filters }.
Keyboard shortcuts
addShortcut(keys, handler) registers a shortcut that works anywhere in the panel and returns the function that removes it. Write a single key, a combination such as mod+k, or a sequence such as g u ("press g, then u" within one second):
import { addShortcut, useShortcut, visit } from '@lathephp/core';
const remove = addShortcut('g u', () => visit('pages/ui-gallery'));
function Search() {
useShortcut('mod+k', () => openPalette());
return null;
}
A single key does not fire while the user types in a field. disableShortcut(keys) turns one off, a built-in included, until enableShortcut(keys). pauseShortcuts() stops all of them and resumeShortcuts() undoes one pause, so pauses add up. usePauseShortcuts(active) pauses while a component is mounted, as a modal does. shortcutsEnabled() tells whether the app turned shortcuts off with keyboard_shortcuts.enabled.
Hooks
| Hook | Returns |
|---|---|
useLathe() |
basePath, apiPath, config, auth |
useTranslation() |
{ __ }, the translator of the user's locale |
useTheme() |
appearance ('light', 'dark' or 'system') and setAppearance |
useResourceIndex(resource, params?) |
A page of a Resource's index |
useResourceDetail(resource, id) |
One record and its detail fields |
useResourceActions(resource, params?) |
The actions of a Resource or a record |
useExecuteAction(resource) |
execute, data, error, isPending for running an action |
useInlineField(resource, id, field) |
save, pending, error, isSaving for saving one field of a record |
useWidgetList(source) and useWidgetValue(source, widget) |
The cards of a dashboard or Resource, and one card's value |
useShortcut(keys, handler, active?) and usePauseShortcuts(active?) |
Nothing. They bind to the component's life |
useNavigationProgress() |
true while the panel loads the next page |
useDebounce(value, delay) |
The value, once it stops changing |
useQuery, useQueryClient, queryClient, queryKeys |
The TanStack Query client the panel shares |
The data hooks return { data, error, isLoading, isFetching, refetch }. They share the panel's query client, so a record you read is cached with the panel's own reads, and an action refreshes it. The translator fills :name, :Name and :NAME placeholders:
const { __ } = useTranslation();
__('Order #:id is on hold.', { id: 12 });
Format values for the user's locale with formatNumber, formatCurrency, formatDate and formatDateTime.
Components
The panel's own building blocks are exported, so a custom page looks like the rest of the panel: AppLayout, ResourceTable, FilterMenu, ActionDropdown, DetailFieldPanel, DetailHeader, WidgetGrid, WidgetCard and Excerpt.
UI kit
Import components from @lathephp/ui, never from Base UI. The main entry holds the everyday set: Accordion, Alert, AlertDialog, Avatar, Badge, Button, Card, Checkbox, Dialog, DropdownMenu, Input, Kbd, Label, Popover, Progress, RadioGroup, Select, Separator, Sheet, Skeleton, Slider, Spinner, Switch, Table, Tabs, Textarea and Tooltip, with their parts. Heavier components load from a subpath, for example @lathephp/ui/calendar. See Plugin distribution for the list.
Run code before the first render
The panel loads every plugin module before it first renders. A registration you make while the module loads is ready in time. For explicit ordering, put it in booting():
import { booting, field } from '@lathephp/core';
booting(() => {
field('rating-field', { form: RatingForm });
});
A callback registered after the panel booted runs at once.
Types
@lathephp/core exports the types you need as you write components: FieldProps, IndexFieldProps, DetailFieldProps, FilterProps, WidgetProps, PluginPageProps, PageProps, ResourcePluginProps, ActionModalProps, RenderHookProps, RenderHookName, SlotProps, FieldData, FilterData, WidgetData, ActionData, ResourceRow, ResourceMeta, LayoutBlock, PaginatedResponse, ResourceIndexResponse, ResourceDetailResponse, NotificationData, SharedData and more. Run pnpm exec tsc in your project to check your code against them.
Test your components
@lathephp/core/testing exports the same API without the guard that throws outside the panel, plus mockLathe(data) to stand in for the panel's shared data. Alias @lathephp/core to it in your Vitest config. Plugin distribution shows how.
What you see
Components you register draw in the panel where their key is used: a field in the form, a page under the sidebar entry that links to it, a hook at its position. A toast from toast.success() appears in the corner for four seconds. A shortcut such as g u runs its handler when you press the keys in turn.