Версія v1.6.5 Попередні версії: документація на GitHub
Розширення: публічний API
На цій сторінці
- Поняття
- Модель довіри
- Швидкий старт
- Структура пакета
- Реєстрація: Larsadmin::extension()
- Розширення в застосунку
- defineExtension() і requires
- Власні поля
- PHP-клас
- Vue: форма
- Vue: відображення
- Поля, що надсилають файли
- Хуки збереження: fillInto() і FileJournal
- Аудит: hasAuditValue() і auditValue()
- Прив'язка до Entity: bindTo()
- Поле в модальному вікні дії
- Картки
- Статична картка
- DataCard
- entityQuery()
- Кешування
- Vue: useCardData() і CardShell
- Фільтри
- Tools: сторінки в меню
- Маршрути розширення
- findRecord()
- findField()
- audited()
- Переклади
- Стилі й теми
- Публічний JS API
- Модуль larsadmin
- Kit автора (vendor/maxcxam/larsadmin/kit)
- Перевизначення вбудованих компонентів
- Що можна перевизначити
- Контракт
- Безпека й fallback
- Недоступні й зламані розширення
- CSP і nonce
- Версії й сумісність
- Перевірка публічного API
- Тестування
- Pest (PHP)
- vitest (Vue)
- Збірка й публікація
- Усунення проблем
- HTTP-ендпоінти
- Обмеження
З версії 1.5 панель має публічний API розширень. Розширення є звичайним Composer-пакетом з PHP-класами та зібраними Vue-компонентами. Воно додає власні поля, картки дашбордів, фільтри, сторінки в бічному меню (tools), власні HTTP-маршрути й переклади, а також може замінити інтерфейс кількох вбудованих компонентів.
Розширення встановлюється так:
composer require acme/larsadmin-rating
Хосту не потрібні npm, vendor:publish чи правки конфігу: пакет реєструється у своєму провайдері, а
панель сама віддає його файли. npm потрібен лише автору розширення, щоб зібрати власні Vue-компоненти.
Усе, що описано на цій сторінці, є стабільним API 1.x (див. Версії й сумісність).
Що можна розширювати без JavaScript (Entity, дії, метрики, події), описано в Розширення: огляд.
Версія 1.6 додала права запису для полів (authorizedTo()), поля розширень у модальних вікнах дій
(inAction(), actionValue(), prop action) і публічний Files\FileSafety; розширення, що ними
користується, декларує requires: '^1.6'.
Поняття
| Що | PHP | Vue (у defineExtension()) |
|---|---|---|
| Поле | спадкоємець Maxcxam\Larsadmin\Fields\Field з $component = 'acme/rating' |
fields: { 'acme/rating': { form, display } } |
| Статична картка | спадкоємець Maxcxam\Larsadmin\Metrics\Card |
cards: { 'acme/welcome': Welcome } |
| Картка з даними | спадкоємець Maxcxam\Larsadmin\Metrics\DataCard |
cards: { 'acme/top-rated': TopRated } + useCardData() |
| Фільтр | спадкоємець Maxcxam\Larsadmin\Filters\Filter з $component = 'acme/stars' |
filters: { 'acme/stars': StarsFilter } |
| Сторінка (tool) | спадкоємець Maxcxam\Larsadmin\Tools\Tool |
tools: { 'acme/backups': BackupsPage } |
| Перевизначення | (нічого) | fields: { 'date-field': { form: Picker, override: true } } |
Сервер і клієнт зіставляються ключем компонента. Ключі розширень мають вигляд vendor/name
(^[a-z0-9-]+\/[a-z0-9-]+$), ключі без / належать панелі. Префікс larsadmin/ зарезервовано для
першосторонніх пакетів (наприклад, демо-розширення в sandbox), app/ для розширення всередині
застосунку.
Як це працює: панель збирає Vue, Inertia і свій SDK в окремі модулі й додає на сторінку
import map, який зіставляє
specifier-и vue, @inertiajs/vue3 і larsadmin з цими модулями. Розширення збирається як ES-модуль, у
якому ці імпорти лишаються зовнішніми. Тому панель і всі розширення працюють з одним екземпляром Vue
та Inertia: ref, provide/inject і usePage() у розширенні бачать ту саму сторінку, що й панель.
Модель довіри
Встановлене розширення є довіреним кодом. Його PHP і так виконується з повними правами застосунку, а його JavaScript виконується на сторінках панелі з правами поточного адміністратора: читає props сторінки й може робити будь-які запити, які може робити користувач. Встановлюйте лише розширення, яким довіряєте, як і будь-який інший пакет.
Межа безпеки лишається на сервері: усі дані й дії авторизує сервер (policies, canSee(), скоупінг
indexQuery()/detailQuery(), валідація). JavaScript розширення не може обійти ці перевірки більше, ніж
користувач у DevTools. Звідси головне правило для автора: кожен маршрут розширення, що працює із
записом, авторизує його на сервері через Larsadmin::findRecord().
Код розширень отримує лише користувач, який пройшов гейт панелі (viewLarsadmin). Гість і сторінка
входу не завантажують жодного файлу розширень.
Що ще робить панель:
- віддаючи файл розширення, перевіряє вже розв'язаний шлях (
realpath), а не лише запитаний, тож обхідні імена (наприклад, короткі імена Windows 8.3 на кшталтVITE~1) не відкривають dotfiles і файли поза дозволеними; - генератори (
larsadmin:extension,:field,:card,:tool, а з 1.6 усі командиlarsadmin:*, що створюють класи, і MCP-інструментgenerate) перевіряють увесь план до першого запису й відмовляються писати поза коренем застосунку, якщо на шляху під ним є будь-яке символічне посилання чи junction (зокрема «висяче») або сегмент лише з крапок чи з крапкою в кінці; каталог пакета розширення не може бути вvendor,node_modules,storage,bootstrap,publicчи теці, що починається з крапки (без урахування регістру), аgenerateстворює розширення лише підpackages/чиextensions/; useJsonRequest()додає CSRF-токен лише до запитів на той самий origin.
Розширення, що зберігає чи віддає файли, користується публічним Maxcxam\Larsadmin\Files\FileSafety (з 1.6):
ті самі перевірки завантажень і заголовки відповіді, що й у вбудованих File/Image
(Файли й зображення). Не копіюйте внутрішній код безпеки панелі;
якщо копія все ж неминуча, покрийте її тестами паритету з оригіналом, щоб вона не розійшлася з ним
після оновлення панелі.
Швидкий старт
Скелет пакета з робочим прикладом поля й картки створює artisan-команда:
php artisan larsadmin:extension acme/larsadmin-rating
Команда пише пакет у {--path}/{vendor}/{package}, тобто packages/acme/larsadmin-rating/, і друкує
наступні кроки:
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
Провайдер скелета реєструє розширення, лише коли існує dist/, тож після збірки воно зареєстроване. Додайте приклад поля в Entity і картку в дашборд:
use Acme\LarsadminRating\Cards\ExampleCard;
use Acme\LarsadminRating\Fields\ExampleField;
ExampleField::make('Example field', 'example_field'), // у fields() Entity
ExampleCard::make(), // у cards() дашборда чи Entity
Готово: форма показує поле розширення, а дашборд картку з даними з її ендпоінта.
Опції команди:
| Опція | За замовчуванням | Призначення |
|---|---|---|
--path= |
packages |
каталог відносно кореня застосунку; пакет потрапляє в PATH/VENDOR/PACKAGE |
--field= |
ExampleField |
клас прикладу поля; ключ {vendor}/example-field |
--card= |
ExampleCard |
клас прикладу DataCard; ключ {vendor}/example-card |
--force |
перезаписати наявні файли |
Окремі частини додають команди larsadmin:field, larsadmin:card і larsadmin:tool з опцією
--extension= (каталог пакета відносно кореня застосунку):
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
| Команда | Створює |
|---|---|
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, з --data DataCard), resources/js/cards/{name}Card.vue |
larsadmin:tool {name} |
src/Tools/{name}.php, resources/js/tools/{name}Page.vue |
Ключ компонента береться з vendor пакета: acme/rating, acme/top-rated, acme/backups. Команди не
редагують extension.js, а друкують рядки для defineExtension() (і для tool рядок
Larsadmin::tools([...]) для провайдера). Без --extension клас потрапляє в app/Larsadmin/{Fields,Cards,Tools},
Vue-файли в resources/larsadmin/js/..., а ключ має вигляд app/rating (див.
Розширення в застосунку).
Розробка панелі з гілки. Скелет вимагає
maxcxam/larsadmin: ^1.6. Якщо панель встановлена з гілки чи path-репозиторію, Composer бачить її якdev-…: гілкаmainмаєbranch-aliasdev-main → 1.x-dev, а для path-репозиторію задайте"options": {"versions": {"maxcxam/larsadmin": "1.6.x-dev"}}. Команда попереджає про це й пропонуєcomposer require … :@dev -W.
Структура пакета
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/ # результат npm run build; комітиться
└── README.md
vite.config.js скелета шукає kit панелі (kit/vite.js, kit/sdk.js, kit/testing.js, kit/theme.css)
спершу у власному vendor/ пакета (після composer install у його каталозі), потім у vendor/
застосунку, і задає alias larsadmin-kit на знайдений каталог. Kit постачається в Composer-архіві панелі
(vendor/maxcxam/larsadmin/kit), окремого npm-пакета немає.
dist/ комітиться, як і dist/ самої панелі: хост, що встановлює розширення, не запускає npm.
Реєстрація: Larsadmin::extension()
Розширення реєструється одним викликом у 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 приймає назву Composer-пакета й
абсолютний шлях до каталогу зі зібраними файлами. За замовчуванням панель завантажує
dist/extension.js і, якщо він є, dist/extension.css. Виклик кидає InvalidArgumentException, якщо:
$nameне є коректною назвою Composer-пакета (vendor/package);$pathпорожній, відносний, не існує чи сам є символічним посиланням (junction у Windows);- ця назва вже зареєстрована з іншим каталогом.
Повторний виклик з тією самою назвою й каталогом повертає той самий об'єкт (провайдери можуть завантажуватися повторно в Octane й тестах). Окремого перемикача «вимкнути розширення» немає: реєстрація і є ввімкненням, а видаляється розширення з Composer.
Fluent-методи Maxcxam\Larsadmin\Extensions\Extension:
| Метод | Призначення |
|---|---|
script(string $file = 'extension.js') |
entry-модуль відносно $path |
style(?string $file = 'extension.css') |
стилі; null вимикає, відсутній файл пропускається |
translations(string $namespace, string $group = 'ui') |
віддати trans("{$namespace}::{$group}") у JavaScript (див. Переклади) |
version(): string |
12 символів md5 від .vite/manifest.json (або від entry-скрипта й стилів); стоїть в URL файлів |
url(string $file): string |
версійований URL файлу з $path |
$name, $path |
назва й realpath каталогу (readonly) |
Також є Larsadmin::registeredExtensions() (усі, у порядку реєстрації) і Larsadmin::extensionFor($name).
Файли віддає маршрут панелі GET {path}/extensions/{vendor}/{package}/{version}/{file} прямо з
зареєстрованого каталогу, без публікації в public/. Версія стоїть у шляху, тож відносні імпорти чанків
розширення її теж отримують, а браузер кешує файли на рік (immutable). Коли версія змінюється
(composer update), змінюється й Inertia-версія панелі: відкриті вкладки перезавантажуються під час
наступної навігації. Деталі в HTTP-ендпоінтах.
Розширення в застосунку
Той самий механізм працює без пакета. Застосунок реєструє каталог у своєму
app/Providers/LarsadminServiceProvider.php:
public function boot(): void
{
parent::boot();
Larsadmin::extension('app/admin', resource_path('larsadmin/dist'));
}
і збирає resources/larsadmin/js/extension.js окремим конфігом Vite з тим самим пресетом:
// vite.larsadmin.config.js; збірка: 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 і larsadmin:tool без --extension створюють файли саме тут, з
ключами app/…. name у defineExtension() має бути 'app/admin'.
defineExtension() і requires
Entry-модуль розширення має default export з результатом 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', // те саме, що в Larsadmin::extension()
requires: '^1.6', // діапазон API панелі
fields: {
'acme/rating': { form: RatingInput, display: RatingDisplay },
},
cards: { 'acme/top-rated': TopRatedCard },
filters: { 'acme/stars': StarsFilter },
tools: { 'acme/backups': BackupsPage },
setup({ app }) {
// необов'язково: app.component(), app.directive(), плагіни Vue
},
})
defineExtension()лише перевіряє форму об'єкта (помилка:TypeErrorз назвою розширення) і позначає його. Реєструє компоненти завантажувач панелі.nameмає збігатися з назвою, переданою вLarsadmin::extension(), інакше розширення не завантажується.requiresпідтримує лише форму^MAJOR.MINOR. Вона задоволена, коли major збігається зapiVersionпанелі, а minor панелі не менший.'^1.6'працює з API1.6,1.7, … і не працює з1.5чи2.0. Розширення, що не користується API 1.6, може лишити'^1.5'. Несумісне розширення не завантажується, а його поля fail closed.- Частини поля:
form(форми створення й редагування),display(index і detail), необов'язковіindexіdetail(замінюютьdisplayу своєму контексті) іpanel(окремий блок detail-сторінки, див. Відображення). setup({ app })викликається один раз, коли Vue-застосунок уже створено. Помилка вsetupпозначає розширення як незавантажене й знімає всі його ключі.- Компоненти можна завантажувати ліниво:
defineAsyncComponent(() => import('./Heavy.vue')). Чанки розширення завантажуються відносно його URL. - Ключ без
/чи зайнятий іншим розширенням відхиляється зconsole.error, решта розширення працює. Перший зареєстрований ключ виграє. - Вбудований ключ без
override: trueвідхиляється зconsole.warn(див. Перевизначення).
Без збірки розширення можна написати лише render-функціями (h()): панель постачає Vue без компілятора
шаблонів.
Власні поля
PHP-клас
Власне поле є спадкоємцем Field (або Text, Number, …) з ключем розширення в $component. Робочий
приклад з демо-розширення sandbox:
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')->max(5)->nullable()->sortable(),
Стабільні члени Field для спадкоємців:
| Член | Призначення |
|---|---|
public string $component |
ключ Vue-компонента; для розширень обов'язково vendor/name |
jsonSerialize(): array |
[...parent::jsonSerialize(), 'max' => …]; ключі спадкоємця перемагають базові |
withMeta(array $meta) |
довільні props без перевизначення jsonSerialize() |
$multipart, multipart(bool $multipart = true) |
поле надсилає файли (див. нижче) |
resolveValue(), formatForDisplay(), fillValue(), fillString(), typeRules(), implicitRules(), acceptsNullInput(), defaultAlign() |
захищені хуки |
fill(Request, Model): ?Closure |
заповнення; повернуте замикання виконується після save() (для пов'язаних даних) |
fillInto(Request, Model, FileJournal): ?Closure |
заповнення з журналом файлів збереження |
fillAttribute(), resolve(), eagerLoads(), applySort() |
публічні хуки |
bindTo(Request, string $entity) |
поле дізнається свій Entity |
authorizedTo(string $ability): bool |
з 1.6: чи може користувач view/update/delete запис, для якого поле розв'язується (Поля) |
inAction(): bool |
з 1.6: поле належить fields() дії |
actionValue(Request): mixed |
з 1.6: хук, значення поля для ActionFields дії (Дії) |
hasAuditValue(), auditValue(Model) |
аудит поля без колонки |
__construct(string $name, ?string $attribute = null, ?callable $resolveCallback = null), resolveDefault(), rulesFor(), validationAttributeNames(), isRequired(), sortable() |
хуки, які можна перевизначати (наприклад, поле без колонки забороняє sortable()) |
Перевизначати можна лише члени з цієї таблиці. Перевизначення будь-якого іншого (isShownOn(),
isReadonlyFor(), serialize(), …) не є API, і перевірка публічного API на
ньому падає.
Серіалізовані ключі базового поля: component, name, attribute, value, displayedAs, sortable,
nullable, readonly, required, help, placeholder, align, wrap, truncate, fullWidth,
multipart (лише коли true) і inAction (з 1.6, лише true для поля дії). У мінорних версіях можуть з'являтися нові ключі, тож називайте власні
ключі так, щоб вони не збіглися з майбутніми (наприклад, ratingMax), а в компонентах ігноруйте невідомі.
Правила, видимість (showOnIndex(), canSee(), …), readonly(), sensitive() та інші методи
працюють так само, як для вбудованих полів (Поля).
Vue: форма
Компонент форми малює лише контрол. Label, позначку «обов'язкове», help і текст помилки малює панель; стан форми, «брудну» форму, скидання й надсилання теж веде панель, тож компонент ніколи не надсилає форму сам.
| Prop / подія | Тип | Зміст |
|---|---|---|
field |
Object | серіалізоване поле: attribute, name, value, readonly, required, placeholder, help, власні ключі |
modelValue / update:modelValue |
any | значення у стані форми; початкове field.value. Не змінюйте field |
invalid |
Boolean | є помилка валідації поля |
errors |
Array<string> | повідомлення для attribute і вкладених ключів attribute.* |
errorsByKey |
Object | ті самі повідомлення за ключами: { 'gallery': '…', 'gallery.upload.1': '…' } |
describedBy |
String | null | id елемента з помилкою чи help, для aria-describedby |
context |
'create' | 'update' |
контекст форми; у модальному вікні дії 'create' |
resourceId |
String | Number | null | ключ запису під час редагування; у дії ключ запису, якщо дія виконується над одним записом, інакше null |
action |
Object | undefined | з 1.6: { uriKey, entity } у модальному вікні дії (Дії); форма запису його не передає |
Вимоги: кореневий фокусований елемент має :id="field.attribute" (на нього вказує for label-а) і
поважає 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>
Сервер валідує значення як завжди (правила поля), тож перевірка на клієнті лише для зручності.
Vue: відображення
Компоненти display, index і detail отримують:
| Prop | Зміст |
|---|---|
field |
серіалізоване поле з value і displayedAs (у таблиці displayedAs уже обрізаний за truncate) |
context |
'index' | 'detail' |
index і detail необов'язкові: без них в обох контекстах працює display.
Частина panel малює окремий блок під полями detail-сторінки для поля, серіалізованого з panel: true
(наприклад, ->withMeta(['panel' => true])). Компонент отримує field, entity ({ uriKey }) і
record ({ id }), як вбудована панель зв'язків.
Поля, що надсилають файли
Поле, яке завантажує файли, вмикає multipart:
class Gallery extends Field
{
public string $component = 'acme/gallery';
public bool $multipart = true; // або ->multipart() на екземплярі
}
Поле серіалізується з multipart: true, і форма надсилається як multipart/form-data (оновлення як
POST з _method=PUT), так само, як форма з полем File. Значення поля надсилається як є: Inertia
перетворює об'єкти з File на записи FormData. Тобто компонент емітить, наприклад,
{ present: 1, upload: [file1, file2] }, а сервер читає $request->file("{$attribute}.upload").
Обмеження multipart: порожній масив не передається зовсім. Якщо значення вашого поля масив, надсилайте
разом з ним прапорець присутності (як present вище) або вважайте відсутній ключ порожнім масивом
(див. Розширення: огляд).
fileField: true є внутрішнім ключем, який надсилають File, Image та їхні спадкоємці: за ним форма
перетворює значення на формат вбудованих полів файлів ({ present, known, keep, upload }). Власні поля не
повинні його задавати. Поле розширення, чиє значення лише схоже на цей формат, надсилається без змін.
Помилки вкладених ключів (gallery.upload.1) приходять у errors і errorsByKey; панель показує під
полем першу з них.
Поле з multipart: true у fields() дії не підтримується (LogicException, як для File/Image):
дія заповнює тимчасову модель, і завантаження лишилися б без власника.
Хуки збереження: fillInto() і FileJournal
Диски не транзакційні. Щоб файли, які записало поле, не лишалися після невдалого збереження,
перевизначте fillInto() замість fill() і реєструйте файли в журналі збереження:
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');
// Видаляється, якщо збереження завершиться помилкою чи rollback-ом.
$journal->created('public', $path);
}
foreach ($record->galleryImages()->whereIn('id', $this->removedIds())->get() as $image) {
$image->delete();
// Видаляється лише після коміту зовнішньої транзакції.
$journal->obsolete('public', $image->path);
}
};
}
Entity::save()викликаєfillInto()для кожного поля. За замовчуваннямfillInto()викликаєfill(), тож наявні поля працюють як раніше.$journal->created(string $disk, string $path): файл, який записало це збереження. Видаляється, якщо збереження не вдалося, зокрема коли помилка сталася у відкладеному замиканні.$journal->obsolete(string $disk, string $path): файл, який збереження зробило зайвим. Видаляється після коміту.- Решта
FileJournal(створення,discardCreated(), очищення) внутрішня. - Журнал є лише в збереженні форми. Маршрути розширення керують файлами самі.
Повний приклад: поле Media доповнення
maxcxam/larsadmin-medialibrary (випущене як 1.0, код у
packages/larsadmin-medialibrary монорепо) реєструє через журнал файли медіа, їхні перетворення й відповідальні
зображення та файли видалених медіа. Це повне розширення лише на API цієї сторінки: multipart-поле, fillInto(),
bindTo(), authorizedTo(), значення аудиту, маршрути розширення з findRecord(), findField() і audited(),
та FileSafety.
Аудит: hasAuditValue() і auditValue()
Журнал аудиту записує значення колонок моделі. Поле без колонки (медіа, теги в іншій таблиці, …) потрапляє в аудит, якщо повертає своє значення само:
public function hasAuditValue(): bool
{
return true;
}
public function auditValue(Model $record): mixed
{
// Свіжий запит, не кешований зв'язок: значення читається до й після збереження.
return $record->tags()->orderBy('name')->pluck('name')->all();
}
auditValue() має повертати значення, придатне для json_encode, і бути дешевим: воно виконується двічі
на кожне збереження з увімкненим аудитом. sensitive() маскує його як завжди. Вбудовані поля цих хуків
не перевизначають, тож їхній аудит не змінився. Зміни з маршрутів розширення аудитуються через
Larsadmin::audited().
Прив'язка до Entity: bindTo()
bindTo(Request $request, string $entity) викликається один раз, коли Entity будує свої поля. Поле, якому
потрібен свій Entity (щоб будувати URL маршрутів розширення чи перевіряти policy), запам'ятовує його:
protected ?string $entity = null;
public function bindTo(Request $request, string $entity): void
{
$this->entity = $entity; // class-string<Entity>
}
Права запису (view, update, delete) поле отримує через authorizedTo() (з 1.6) без власних
викликів policy: це відповіді, які панель уже обчислила для рядка (Поля).
Поле в модальному вікні дії
З 1.6 поля розширень працюють у fields() дії. Поле знає про це з inAction() (його context лишається
create), а значення для ActionFields дає хук actionValue(Request $request): за замовчуванням це те, що
fill() пише в fillAttribute() тимчасової моделі. Перевизначте його, якщо fill() пише значення
інакше; для фонових дій значення має переживати JSON. Компонент форми отримує додатковий prop action.
Докладно: Дії: поля розширень у модальному вікні.
Картки
Статична картка
Статична картка не має даних з сервера, лише 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' => [...]]),
Стабільні члени Card: $component, $width, $meta, name(), uriKey(), width(), help(),
canSee(), withMeta(array $meta) (додає ключі після базових; базовий ключ component, uriKey, name,
width, helpText чи endpoint кидає InvalidArgumentException) і jsonSerialize(). Картка отримує в
Vue prop card з ключами component, uriKey, name, width, helpText, endpoint і власними.
Картки реєструються в cards() дашборда чи Entity, як метрики (Дашборди).
<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 завантажує дані з власного JSON-ендпоінта картки. Це той самий
ендпоінт і та сама авторизація, що в метрик: гейт панелі, видимість дашборда (або viewAny Entity для
карток над списком), canSee() картки, ліміт larsadmin-metrics.
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(),
];
}
}
| Метод | Призначення |
|---|---|
rules(): array |
правила валідації query-параметрів; невалідні дають 422, усі інші параметри відкидаються |
data(CardRequest $request): mixed |
дані картки, придатні для json_encode |
cacheFor() |
строк кешу (int секунд, DateInterval, DateTimeInterface); null (за замовчуванням) вимикає |
cacheKeyParts(CardRequest $request): array |
додаткові частини ключа кешу |
entityQuery(CardRequest $request, string $entity, ?Closure $constraint = null): Builder |
захищений: запит Entity зі скоупом |
Ендпоінт відповідає 200 {"data": …}. Сирий $request->request->query() у data() не
використовуйте: валідовані параметри є лише в CardRequest.
Maxcxam\Larsadmin\Metrics\CardRequest (readonly):
| Член | Зміст |
|---|---|
$request |
поточний Illuminate\Http\Request |
$parameters |
лише провалідовані ключі з rules() |
$timezone |
часовий пояс панелі |
$now |
поточний момент (CarbonImmutable) у цьому поясі |
$owner |
dashboard:{key} або entity:{key} |
user() |
користувач guard-а панелі |
get(string $key, mixed $default = null) |
провалідований параметр (dot-нотація) |
CardRequest будує панель (конструктор внутрішній); у тестах користуйтеся cardData(). Методи
DataCard::cardRequest(), resolve() і cacheKey() внутрішні, а resolve() і cacheKey() ще й final:
не називайте так власні методи картки.
entityQuery()
entityQuery() є відповідником Metric::scoped() для карток з даними: повертає запит Entity, обмежений
його indexQuery(), після перевірки viewAny. Без viewAny кидає AuthorizationException, і
ендпоінт відповідає 403. $entity приймає лише зареєстрований Entity: його клас або uriKey; інше
кидає InvalidArgumentException. Обмеження
$constraint загортається в окрему групу where, тож orWhere усередині не виходить за межі скоупу.
data() без entityQuery() бачить усе, що може прочитати запит: моделлю чи query builder-ом користуйтеся
лише для глобальних даних і ховайте таку картку через canSee().
Кешування
public function cacheFor(): int
{
return 300;
}
public function cacheKeyParts(CardRequest $request): array
{
return [$request->user()?->current_team_id];
}
Ключ кешу містить власника (дашборд чи Entity), клас і uriKey картки, провалідовані параметри, часовий
пояс, мову, guard і користувача (клас + id), результат Larsadmin::metricCacheScopeUsing() і
cacheKeyParts(). Сховище: larsadmin.metrics.cache_store.
При попаданні в кеш data() не викликається, а повторно виконуються авторизація власника (дашборд чи
viewAny Entity), canSee() картки і viewAny кожного Entity, з якого дані прочитано через
entityQuery(): користувач, у якого це право відкликали, отримує 403 і на закешованих даних. Інших
джерел (модель, query builder, зовнішній API) картка не знає. Тому:
- якщо
data()залежить від чогось, крім користувача й параметрів (тенант, команда, роль, сесія), поверніть це зcacheKeyParts()або з глобальногоmetricCacheScopeUsing(), інакше користувач отримає дані чужої області; - власні перевірки доступу всередині
data()на закешованих даних не спрацюють, переносьте їх уcanSee().
Колбек metricCacheScopeUsing() отримує картку другим аргументом. Якщо цей параметр типізовано лише як
Metric, колбек картку отримати не може, і така картка ніколи не кешується (fail closed). Типізуйте
параметр як Metric|DataCard, Card чи mixed, якщо хочете кешувати картки з даними.
Vue: useCardData() і 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 = {}) робить GET card.endpoint з params у query і повертає
{ status, data, reload }. card і params можуть бути значенням, ref-ом або getter-ом; ref чи getter
params відстежується, і дані перезавантажуються при зміні. status дорівнює idle, loading,
error або forbidden; data тримає останнє вдале значення під час перезавантаження і стає null після
помилки.
CardShell є оболонкою картки, як у вбудованих метрик: заголовок, tooltip з help(), skeleton,
стани «немає доступу» й «помилка» з кнопкою «Повторити» (подія retry).
| Prop / слот | Зміст |
|---|---|
card |
серіалізована картка (обов'язковий) |
status |
статус з useCardData() / useJsonResource() |
ready |
є що показати (поки false під час першого завантаження, видно skeleton) |
empty |
показати стан «немає даних» замість слота |
skeletonClass |
клас skeleton-а, за замовчуванням h-24 |
слоти default, actions |
вміст і елементи в шапці (наприклад, вибір періоду) |
Фільтри
Фільтр розширення є звичайним спадкоємцем Filter (Фільтри) з ключем розширення в
$component; серверна логіка (apply(), normalize(), options()) не змінюється.
class StarsFilter extends Filter
{
public string $component = 'acme/stars';
// apply(), normalize(), options() як у будь-якого фільтра
}
Vue-компонент отримує filter (key, label, component, options для фільтрів з варіантами,
value, default) і modelValue / update:modelValue. Власні props додає стабільний хук
serialize(Request $request, mixed $value): array; результат має містити всі базові ключі
parent::serialize():
public function serialize(Request $request, mixed $value): array
{
return [...parent::serialize($request, $value), 'max' => 5];
}
Значення потрапляє в query-рядок, тож це лише
скаляр або плоский об'єкт рядків. Сервер нормалізує його через normalize().
Tools: сторінки в меню
Tool є сторінкою в бічному меню (розділ «Інструменти») з власним Vue-компонентом.
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() провайдера розширення чи застосунку
Larsadmin::tools([
Backups::class,
// Backups::make()->canSee(fn (Request $request) => $request->user()->isAdmin()),
]);
| Член | Призначення |
|---|---|
$component |
ключ компонента розширення, обов'язково vendor/name |
name() |
назва в меню й заголовок сторінки; за замовчуванням __() від назви класу |
uriKey() |
ключ в URL; за замовчуванням kebab-case назви класу |
icon() |
іконка меню з Tool::ICONS; null чи невідома назва дає іконку за замовчуванням |
canSee(Closure) / authorizedToSee(Request) |
видимість; без canSee() tool бачать усі, хто пройшов гейт панелі |
props(Request $request): array |
props сторінки (JSON) |
make(...$args) |
створити екземпляр |
Сторінка: GET {path}/tools/{uriKey}. Невідомий чи невидимий tool відповідає 404 (не 403, щоб не
розкривати його існування). Larsadmin::tools() кидає InvalidArgumentException для об'єкта, що не є
Tool, для ключа без / і для двох класів з однаковим uriKey. Також є Larsadmin::registeredTools() і
Larsadmin::toolForKey($key) (без перевірки видимості).
Vue-компонент отримує tool (name, uriKey, component) і props (з props()); панель малює його в
своєму layout під заголовком tool.name. Дані, яких немає в props(), компонент завантажує з
маршрутів розширення.
<script setup>
defineProps({
tool: { type: Object, required: true },
props: { type: Object, default: () => ({}) },
})
</script>
<template>
<div class="larsadmin-box p-4">{{ props.lastRun }}</div>
</template>
Іконки (Tool::ICONS, у 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.
Маршрути розширення
Власні JSON-маршрути реєструються через Larsadmin::routes() у провайдері:
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');
});
Маршрути потрапляють у групу {path}/ext з іменами larsadmin.ext.… і middleware панелі
(larsadmin.middleware + larsadmin.auth_middleware), тобто POST /admin/ext/acme-rating/posts/1/reset
з іменем larsadmin.ext.acme-rating.reset. Мутаційні маршрути отримують CSRF від групи web. Той самий
об'єкт Closure реєструється один раз. З route:cache замикання виконуються під час кешування, як
звичайні маршрути пакетів.
routes() гарантує лише гейт панелі. Решту авторизації (policy, скоупінг, видимість поля) маршрут
робить сам, через помічники нижче.
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]);
}
}
З Vue такий маршрут викликається через 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
Знаходить запис так, як це робить панель: viewAny Entity (403), скоуп detailQuery() (404 для рядків
поза ним), потім ability policy на записі (403). $entity є uriKey Entity; невідомий дає 404.
$ability: view, update або delete (інше кидає InvalidArgumentException). Викликайте його в
кожному маршруті, що працює із записом.
findField()
Larsadmin::findField(Request $request, string $entity, Model $record, string $attribute, string $context = Field::UPDATE): Field
Повертає поле Entity для запису так, як його бачить панель: прив'язане (bindTo()), показане в
$context цьому користувачу й для цього запису (інакше 404), а для create/update ще й не
readonly (інакше 403). Повертає клон, тож його можна resolve()-ити. Викликайте після findRecord().
$context: Field::INDEX, DETAIL, CREATE або UPDATE. З 1.6 authorizedTo() поля відповідає для
цього запису (у create завжди false); policy питається лише тоді, коли поле викликає authorizedTo().
audited()
Larsadmin::audited(Request $request, string $entity, Model $record, Closure $change): mixed
Виконує зміну запису поза формою й записує її в журнал аудиту як updated: знімок полів Entity до,
$change($record), знімок після, різниця, маскування як завжди. Усе в одній транзакції; повертає результат
замикання. Запису немає, якщо нічого не змінилося, аудит вимкнено чи Entity не аудитується. Поля з
hasAuditValue() теж потрапляють у різницю. Запис, що не є моделлю цього Entity, кидає
InvalidArgumentException.
Переклади
Розширення має lang/{locale}/ui.php:
// lang/uk/ui.php
return [
'stars' => ':count зірка|:count зірки|:count зірок',
'not_rated' => 'Без оцінки',
];
Провайдер реєструє файли стандартно й віддає групу в JavaScript:
$this->loadTranslationsFrom(__DIR__.'/../lang', 'acme-rating');
Larsadmin::extension('acme/larsadmin-rating', __DIR__.'/../dist')
->translations('acme-rating'); // група 'ui' за замовчуванням
const { t, tc, locale } = useI18n()
t('acme-rating::not_rated')
tc('acme-rating::stars', 3) // "3 зірки"
У JavaScript ключ має вигляд namespace::key (група не пишеться), вкладені масиви дають
namespace::group.key. У PHP (назви класів, правила) переклади звичайні: __('acme-rating::ui.not_rated').
Вбудовані ключі панелі не містять ::, тож колізій немає. Мови панелі спільні; якщо для мови перекладу
немає, діє fallback-локаль Laravel, а без неї t() повертає ключ. Хост може перевизначити переклади в
lang/vendor/acme-rating (стандарт Laravel). Скелет містить uk і en.
Стилі й теми
Розширення збирає власні utility-класи Tailwind CSS v4 з темою панелі:
/* resources/css/extension.css */
@import "tailwindcss/utilities" layer(utilities.extensions) source(none);
@import "larsadmin-kit/theme.css";
@source "../js";
layer(utilities.extensions)кладе класи в підшар шаруutilitiesпанелі: вони й далі перемагають базові стилі та компоненти панелі, але ніколи — її власні utility-класи. Звичайний@import "tailwindcss/utilities";дає CSS поза шарами, і його.hidden { display: none }перемагає шаровийmd:blockпанелі, ховаючи частини її сторінок (так робив скелет до 1.6.5).source(none)разом з@source "../js": генеруються лише класи компонентів розширення, а не слова зREADME.mdчи PHP-файлів, схожі на класи.- Компроміс: utility-клас розширення програє однойменному класу панелі.
kit/theme.css імпортує стандартну тему Tailwind як reference: класи p-4, size-6, text-sm,
rounded, md:grid-cols-2 генеруються, але змінні теми не дублюються, а беруться з панелі. Кольори
відображено на токени --la-*, тож вони самі змінюються зі світлою й темною темою:
| Utility | Токен |
|---|---|
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 |
інші токени |
Стабільний CSS-контракт: токени --la-* (Теми) і класи
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. Utility-класи з app.css панелі контрактом не є: їх
набір залежить від коду панелі, тож розширення збирає власні.
Для графіків, які малюють кольори самі, useTheme().resolved повертає 'light' чи 'dark'
(вибір користувача або налаштування ОС); палітра графіків: --la-chart-1 … --la-chart-8.
Публічний JS API
Модуль larsadmin
Усі експорти нижче входять у SemVer-контракт 1.x (у мінорах лише додаються):
| Експорт | Опис |
|---|---|
apiVersion |
рядок '1.6' (Larsadmin::API_VERSION) |
defineExtension(options) |
опис розширення (вище) |
useI18n() |
{ t, tc, locale }; ключі розширень t('acme-rating::key') |
useLarsadmin() |
{ url(...segments), entityUrl(uriKey, ...segments) } під шляхом панелі; сегменти кодуються, . і .. заборонені |
useJsonRequest() |
{ send(method, url, body?, { background }) } → { ok, status, data, expired? }; X-XSRF-TOKEN для мутацій лише на URL того самого origin; на 401/419 запит GET чи з background: true повертає expired: true, інший перезавантажує сторінку |
useJsonResource() |
{ status, load(url, { background }), cancel }; скасовує застарілий запит; status: idle, loading, error, forbidden |
useCardData(card, params?) |
{ status, data, reload } для DataCard |
useToasts() |
{ push({ message, type, title?, timeout? }), dismiss(id) }; type: success, info, warning, error; посилання не підтримуються |
useTheme() |
{ resolved }: 'light' | 'dark' |
useNumberFormat() |
{ format(value, options) }: опції Intl.NumberFormat плюс prefix/suffix, мовою панелі; null і NaN дають «—» |
buildUrl(base, segments), withParams(url, params) |
чисті помічники URL |
Icon |
<Icon name="pencil" />; назви в Icon.NAMES (ті самі, що Tool::ICONS); декоративна, підпис дає елемент керування |
CardShell |
оболонка картки (вище) |
ConfirmDialog |
доступний діалог підтвердження |
Також стабільні specifier-и import map vue (Vue 3 runtime, ^3.5) і @inertiajs/vue3 (Inertia v3,
той самий екземпляр, що в панелі): usePage(), Link, router у розширенні працюють зі сторінкою панелі.
З shared props контрактом є лише larsadmin.locale, larsadmin.path і user.
Внутрішнє (не контракт): усе інше в resources/js/** панелі, зокрема registerField,
registerCard, registerFilter, Combobox, EntityForm, FieldBlocks і решта структури shared props.
Імпортувати файли панелі напряму (vendor/maxcxam/larsadmin/resources/js/...) не можна: їх немає в
Composer-архіві.
Kit автора (vendor/maxcxam/larsadmin/kit)
| Файл | Експорти |
|---|---|
kit/vite.js |
larsadmin(options) (і default): пресет Vite |
kit/testing.js |
withPanel(), mountField(), mountDisplay(), mountCard() |
kit/theme.css |
тема Tailwind для розширень |
kit/sdk.js |
збірка модуля larsadmin для vitest (пресет підставляє її сам) |
Перевизначення вбудованих компонентів
Розширення може замінити інтерфейс кількох вбудованих компонентів явним override: true:
export default defineExtension({
name: 'acme/larsadmin-ui',
requires: '^1.5',
fields: {
// лише форма; index і detail лишаються вбудованими
'boolean-field': { form: BooleanSwitch, override: true },
},
cards: {
'value-metric': { component: BigNumberCard, override: true },
},
filters: {
'date-range-filter': { component: RangePicker, override: true },
},
})
Для полів частини (form, display, index, detail) перевизначаються окремо. Картки й фільтри
перевизначаються в об'єктній формі { component, override: true }.
Що можна перевизначити
| Тип | Ключі | Частини |
|---|---|---|
| Поля | text-field, password-field, textarea-field, number-field, boolean-field, select-field, date-field, datetime-field |
form, display, index, detail |
| Фільтри | select-filter, boolean-filter, date-range-filter |
компонент |
| Картки | value-metric, trend-metric, partition-metric, progress-metric |
компонент |
Решта вбудованих ключів (id-field, file-field, image-field, belongs-to-field, has-many-field,
belongs-to-many-field) і частина panel не перевизначаються (console.warn): їхні формати й
ендпоінти внутрішні. Вбудований ключ без override: true теж відхиляється з console.warn. Одну частину
ключа може перевизначити лише одне розширення: перше виграє, наступні відхиляються з console.error.
Контракт
Перевизначення отримує ті самі props і події, що й вбудований компонент, плюс загальний контракт розширень. Усе це стабільне в 1.x:
- поля, форма:
field,modelValue/update:modelValue,invalidі контракт форми (errors,errorsByKey,describedBy,context,resourceId); відображення:field,context. - ключі
field: базові (вище) плюсtext-field/password-fieldinputType;textarea-fieldrows;number-fieldmin,max,step;select-fieldoptions([{ value, label }]). - форма значення в
update:modelValueта сама, що у вбудованого: рядок (text,password,textarea), число (number),true/false(boolean), значення опції (select),YYYY-MM-DD(date-field),YYYY-MM-DDTHH:mm:ss(datetime-field, якdatetime-local). - фільтри:
filter,modelValue/update:modelValue; значення'1','0'чиnullдляboolean-filter,{ from, to }(рядкиYYYY-MM-DD) чиnullдляdate-range-filter, значення опції дляselect-filter. - картки:
cardз ключамиCardплюсranges([{ value, label }]) іdefaultRange. Відповідь ендпоінта{ range, result }; формаresultописана в Метриках.
Внутрішня розмітка, CSS-класи й дочірні компоненти вбудованих компонентів контрактом не є.
Безпека й fallback
- Перевизначення змінює лише клієнтський рендер. Ключ
componentна сервері, правила,fill(),canSee(), policies, скоупінг і формат значення, який приймає сервер, не змінюються: сервер валідує ввід як завжди. - Поки розширення завантажується, рендериться вбудований компонент (без skeleton).
- Якщо розширення не завантажилося, несумісне чи впав його
setup, перевизначення не застосовується. - Якщо компонент-перевизначення падає під час рендера, панель до кінця сесії сторінки повертає цей ключ (частину) до вбудованого компонента й пише помилку в консоль. Значення поля надсилається, бо його обробляє вбудований компонент.
- З
APP_DEBUG=trueпісля завантаження в консолі видноconsole.infoзі списком «хто що перевизначив».
Недоступні й зламані розширення
Помилки розширень ізольовано, панель працює далі:
- Завантажувач імпортує всі розширення паралельно й чекає на них до 5 секунд, потім монтує панель, а
пізні розширення домальовуються, коли завантажаться. Реєструються вони в порядку серверного списку
(порядку
Larsadmin::extension()), а не в порядку завершення імпорту, тож переможець конфлікту ключів не залежить від мережі. Поки хоч одне завантажується, компонент розширення показує skeleton «Завантаження розширення…». - Розширення не завантажується (
console.errorз причиною), якщо імпорт не вдався чи не завершився за 30 секунд, default export не єdefineExtension(),nameне збігається з серверним,requiresнесумісний або впавsetup(). ExtensionBoundaryвимикає компонент розширення до кінця сесії сторінки лише тоді, коли той не може відрендеритися: помилка в render,setup, хуках життєвого циклу чи завантажувачі асинхронного компонента. Помилки в обробниках подій, watcher-ах і асинхронному коді лише пишуться в консоль з назвою розширення (зAPP_DEBUG=trueще й toast): компонент лишається, а введене користувачем значення зберігається й надсилається.
Що бачить користувач на місці недоступного компонента vendor/name:
| Місце | Placeholder |
|---|---|
| форма | рамка «Недоступно: розширення не завантажено» з підказкою «Значення цього поля не буде змінено під час збереження.» |
| index, detail | «—» з підказкою «Недоступно…» |
| картка, tool, panel | блок з назвою й «Недоступно…» |
| фільтр | назва фільтра й «Недоступно…» |
Fail closed у формі. Поле з недоступним компонентом розширення (не завантажилося, несумісне,
впало) не надсилається: сервер лишає збережене значення, а на створенні спрацює required. Ключ без
/, невідомий панелі, як і раніше показується текстовим полем (сумісність з 1.4).
Debug-toast. Лише з APP_DEBUG=true користувач панелі ще й бачить toast «Розширення X не
завантажено» або «Розширення X потребує API ^1.7, а панель надає 1.6». У продакшні є лише консоль і
placeholder-и.
CSP і nonce
Файли панелі й розширень лежать на тому самому origin, тож для них достатньо script-src 'self' і
style-src 'self'. Inline-скрипти панелі (визначення теми й import map), тег app.js і теги modulepreload отримують
атрибут nonce, якщо застосунок увімкнув стандартний механізм Laravel 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;
}
}
Middleware має виконуватися до рендера сторінки панелі (наприклад, у групі web або в
larsadmin.middleware). Без nonce сувора CSP блокує import map: панель працює, а поля розширень показують
«Недоступно». 'strict-dynamic' сумісний: модулі, які імпортує довірений app.js, дозволені. SRI не
застосовується (той самий origin, ті самі файли деплою). Без зареєстрованих розширень import map на
сторінці немає.
Версії й сумісність
Larsadmin::API_VERSION(PHP) іapiVersion(JS) дорівнюють'1.6': цеMAJOR.MINORверсії, у якій востаннє розширювався публічний API. Мінор без змін API його не піднімає.- PHP-сумісність гарантує Composer (
"maxcxam/larsadmin": "^1.6"уcomposer.jsonрозширення;"^1.5"для розширення без API 1.6).requiresу JavaScript ловить застарілийdist/. - Заморожено в межах 1.x: Vue 3 (≥ 3.5) і Inertia v3, specifier-и import map, контракти компонентів
цієї сторінки, експорти модуля
larsadminі kit, CSS-контракт, PHP-члени зі списку стабільних. Перехід на Vue 4 чи Inertia v4 можливий лише в 2.0. - Мінори лише додають: нові props компонентів, нові експорти SDK, нові ключі серіалізації. Розширення мають ігнорувати невідомі props і ключі.
- Депрекації: член отримує
@deprecated(PHP) або одноразовийconsole.warnзAPP_DEBUG=true(JS), лишається до кінця major і видаляється в наступному; CHANGELOG має розділ «Deprecated» із заміною.
Стабільні PHP-класи й члени:
| Клас | Стабільні члени |
|---|---|
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 |
див. PHP-клас поля |
Files\FileJournal |
created(), obsolete() |
Files\FileSafety |
з 1.6: rule(), blockedExtension(), storedExtension(), sanitizeName(), asciiName(), isRasterImage(), response() (Файли й зображення) |
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() |
На решту задокументованих класів панелі (Entity, вбудовані поля, фільтри, метрики, дії, дашборди,
Layout\Panel, Layout\Tabs, події, LarsadminNotification) розширення може посилатися як і застосунок.
Внутрішні члени не є API, навіть якщо вони публічні в PHP: Entity::fieldPrototypes(), isShownOn(),
isReadonlyFor() (для них є findField()), конструктор і допоміжні методи Extension, конструктор
CardRequest, DataCard::cardRequest()/resolve()/cacheKey(), Extension::NAME_PATTERN,
Tool::COMPONENT_PATTERN, Tool::$canSee, Field::withAbilities()/forAction(), Files\FileTypes,
Files\FileResponse і все з позначкою @internal. З 1.6 внутрішніми позначено також Larsadmin::locale(),
locales(), navigation(), searchOrder(), modelUsesScout(), dashboardForKey(), home(),
dashboardNavigation(), metricCacheScope() і timezone().
Перевірка публічного API
Повний список стабільної поверхні лежить у kit/public-api.json, а kit/check-public-api.mjs перевіряє
її (Node ≥ 20, без залежностей). Обидва файли входять у Composer-архів панелі, тож розширення
перевіряється з його каталогу (шляхи відносні до поточного каталогу):
node vendor/maxcxam/larsadmin/kit/check-public-api.mjs .
Скрипт перевіряє дві речі:
- розширення використовує лише стабільну поверхню. JS/Vue/CSS імпортує лише
vue,@inertiajs/vue3, сторонні пакети, власні файли, експортиlarsadminі файли kit (не файли панелі зresources/jsі не підшляхиlarsadmin/...); PHP посилається лише на класиMaxcxam\Larsadmin\…зі списку й не викликає внутрішніх членів; - розширення перевизначає лише стабільні хуки. Клас, що наслідує
Field,Filter,Card/DataCardчиTool(прямо чи через власні класи), може перевизначати лише стабільні члени свого базового класу.
Каталог tests/ розширення не перевіряється: тести можуть посилатися на внутрішні класи, наприклад у
тестах паритету зі скопійованим кодом панелі. У репозиторії панелі npm run check:api перевіряє саму
поверхню: що нічого зі знімка не зникло й нічого не додано без запису в знімок.
Тестування
Pest (PHP)
Скелет містить tests/TestCase.php на Orchestra Testbench (SQLite у пам'яті, міграції Laravel,
фікстурна модель користувача, відкритий гейт панелі) і tests/Pest.php:
pest()->extend(TestCase::class)->use(InteractsWithLarsadmin::class)->in(__DIR__);
Трейт Maxcxam\Larsadmin\Testing\InteractsWithLarsadmin (також для PHPUnit) використовує лише публічний
API:
| Метод | Що робить |
|---|---|
serializeField(Field $field, ?Model $record = null, string $context = Field::DETAIL, ?string $entity = null, array $abilities = []): array |
серіалізує поле, як панель для сторінки; з $entity (клас чи uriKey) спершу викликає bindTo(); $abilities (з 1.6, напр. ['view' => true]) задає відповіді authorizedTo() для запису (без запису й у create ігнорується) |
fillField(Field $field, Model $record, array $input, array $files = [], string $context = Field::UPDATE): Model |
валідує ввід правилами поля (ValidationException), як панель викликає fillInto() з журналом файлів, зберігає й виконує відкладене замикання в транзакції; при помилці видаляє файли, зареєстровані через created() |
cardData(DataCard $card, array $query = [], ?Authenticatable $user = null, string $owner = 'dashboard:test'): mixed |
canSee() (AuthorizationException), rules() (ValidationException), data() |
assertExtensionRegistered(string $name) |
розширення зареєстроване |
extensionAssetResponse(string $name, string $file): TestResponse |
GET версійованого URL файлу як поточний користувач |
it('serializes the rating', function () {
$post = new Post(['rating' => 4]);
expect($this->serializeField(Rating::make('Рейтинг', '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()скидає все статичне, зокрема розширення, маршрути й tools, які зареєстрували провайдери під час boot. Якщо ваші тести його викликають, реєструйте розширення знову вsetUp()післяflushState()або не викликайте його в тестах розширення.- HTTP-тест скелета (
tests/HttpTest.php) перевіряє ендпоінт картки й віддачуdist/extension.js(пропускається, поки немаєnpm run build).
vitest (Vue)
Пресет у режимі тестів підставляє larsadmin → kit/sdk.js, вмикає jsdom і виключає vendor/** і
dist/**. Помічники kit/testing.js (через alias larsadmin-kit скелета) монтують компоненти з
контрактом панелі без Inertia-застосунку:
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 зірка|:count зірки|:count зірок' } }
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')
})
})
| Помічник | Що монтує |
|---|---|
withPanel({ locale, translations, path, props }) |
опції mount, що дають SDK сторінку панелі (useI18n, useLarsadmin, …) |
mountField(Component, { field, modelValue, errors, errorsByKey, action, context, resourceId, panel, options }) |
форму з контрактом; v-model підключено; errors вмикає invalid і describedBy {attribute}-error; action: { uriKey, entity } (з 1.6) монтує поле як у модальному вікні дії (inAction: true, context за замовчуванням 'create') |
mountDisplay(Component, { field, context, panel, options }) |
відображення; displayedAs за замовчуванням String(value) |
mountCard(Component, { card, panel, options }) |
картку з серіалізованими ключами Card |
options передається в mount() @vue/test-utils.
Тестування Entity, policy, дій, фільтрів і метрик застосунку описано в Тестування.
Збірка й публікація
npm run build # dist/extension.js, extension.css, chunks/, .vite/manifest.json
npm run dev # перезбирає після кожної зміни (vite build --watch)
npm test # vitest
vendor/bin/pest # після composer install у каталозі пакета
Пресет larsadmin({ entry = 'resources/js/extension.js', outDir = 'dist' }) збирає ES-бібліотеку
dist/extension.js, усі стилі в один dist/extension.css, чанки в dist/chunks/, manifest для версії й
лишає vue, @inertiajs/vue3 і larsadmin зовнішніми. Запобіжник зупиняє збірку, якщо в бандл
потрапила власна копія Vue, @vue/*, Inertia чи SDK (друга копія Vue є найпідступнішою помилкою: тихо
ламає реактивність і usePage()). Імпортуйте їх лише як vue, @inertiajs/vue3 і larsadmin, без
глибоких шляхів і alias-ів.
Публікація:
- Закомітьте
dist/післяnpm run build. composer.jsonпакета вимагає"maxcxam/larsadmin": "^1.6"і реєструє провайдер вextra.laravel.providers(скелет робить обидва).- Поставте тег версії й опублікуйте пакет (Packagist чи приватний репозиторій).
Хост встановлює пакет composer require і більше нічого не робить. Після composer update змінюється
версія файлів, і відкриті вкладки перезавантажуються під час наступної навігації.
Усунення проблем
| Симптом | Причина й рішення |
|---|---|
| Збірка падає з «contains its own copy of Vue, Inertia or the larsadmin SDK» | імпорт Vue/Inertia через глибокий шлях чи alias, або залежність тягне власний Vue; імпортуйте лише vue, @inertiajs/vue3, larsadmin |
| Поля показують «Недоступно», у консолі нічого про розширення | import map заблоковано CSP: увімкніть Vite::useCspNonce() (CSP) |
| «Extension "…" was not loaded (the module is named …)» | name у defineExtension() не збігається з Larsadmin::extension() |
| «… requires ^1.7, the panel API is 1.6» | requires вищий за API панелі: оновіть панель або понизьте requires |
| Стилі розширення не застосовуються | немає dist/extension.css (не імпортовано CSS у extension.js) або класи не згенеровано (@source "../js") |
| Ключ відхилено з «use override: true» | ключ без / збігся з вбудованим; використайте vendor/name або override: true для дозволеного ключа |
composer require acme/…:^1.6 не знаходить сумісну панель |
панель з гілки чи path-репозиторію: див. швидкий старт |
| Розширення зникло в тестах | тест викликав Larsadmin::flushState(): зареєструйте його знову |
check-public-api.mjs: «… overrides …, which is not a stable override hook» |
клас перевизначає нестабільний член базового класу; перенесіть логіку в стабільний хук |
HTTP-ендпоінти
| Метод | URI (під {path}) |
Ім'я (larsadmin.…) |
Відповіді |
|---|---|---|---|
| GET | extensions/{vendor}/{package}/{version}/{file} |
extensions.asset |
200 файл, 304, 404 (не зареєстровано, traversal, dotfile, тип) |
| GET | api/dashboards/{dashboard}/metrics/{card} |
api.dashboard-metric |
DataCard: 200 {"data": …}; 404 невидимий дашборд чи картка, статична картка; 422 {"message", "errors"} |
| GET | api/entities/{entity}/metrics/{card} |
api.entity-metric |
як вище; 403 без viewAny |
| GET | tools/{tool} |
tools.show |
200 Inertia-сторінка Tool; 404 невідомий чи невидимий |
| будь-який | ext/… |
ext.… |
визначає розширення |
Віддача файлів розширень: лише користувачам, що пройшли гейт панелі (лімітер larsadmin-files); лише
файли всередині realpath зареєстрованого каталогу; dotfiles (зокрема .vite/manifest.json) закриті;
дозволені типи js, mjs, css, json, woff2, woff, ttf, png, jpg, jpeg, gif, webp,
avif, ico, svg (з Content-Security-Policy: sandbox) і map (лише з
LARSADMIN_EXTENSION_SOURCE_MAPS=true). Заголовки: X-Content-Type-Options: nosniff,
Cross-Origin-Resource-Policy: same-origin, ETag і Last-Modified. Поточна версія кешується як
private, max-age={extensions.max_age}, immutable, стара віддається з no-cache.
Конфіг (config/larsadmin.php):
| Ключ | Змінна середовища | За замовчуванням | Опис |
|---|---|---|---|
extensions.source_maps |
LARSADMIN_EXTENSION_SOURCE_MAPS |
false |
віддавати *.map розширень (лише для налагодження) |
extensions.max_age |
31536000 |
строк кешу браузера для версійованих файлів, секунд |
Shared props (лише для користувачів, що пройшли гейт): larsadmin.extensions ([{ name, script, style }]),
larsadmin.extensionTranslations, larsadmin.debug, tools. Без зареєстрованих розширень і tools цих
props немає, а Inertia-версія така сама, як у 1.4.
Обмеження
- Перше завантаження важче приблизно на 27 КБ gzip, ніж у 1.4, навіть без розширень: Vue та Inertia
лежать у спільних чанках, які розширення імпортують через import map, і не проходять tree-shaking.
Далі файли кешуються браузером. Зайвих послідовних запитів немає: кореневий шаблон додає
<link rel="modulepreload">(зnonce) для статичних імпортівapp.jsзаdist/.vite/manifest.json(без manifest тегів немає), тож післяapp.jsбраузер паралельно вантажить чанки vue, inertia і shared. - Поля, що надсилають файли (
File,Image,multipart: true), у модальних вікнах дій не підтримуються. Larsadmin::flushState()знімає розширення, зареєстровані провайдерами під час boot (див. Тестування).FileJournalвидаляє файли, але не каталоги: після відкинутого завантаження можуть лишатися порожні каталоги (наприклад, каталоги медіа spatie/laravel-medialibrary).larsadmin:extensionзавжди пише пакет уPATH/VENDOR/PACKAGE(--path=sandbox/extensionsдаєsandbox/extensions/acme/larsadmin-rating).- Маршрути розширень керують файлами самі: журналу файлів у них немає.
- Розширення без збірки пишуться лише render-функціями (
h()), без шаблонів-рядків. - Немає віддалених скриптів (CDN), npm-пакета kit і TypeScript-типів.
- Немає власних модальних вікон дій, layout-слотів, сторінок Entity, карток на detail-сторінці й залежних полів. Картки лише читають дані; зміни робляться діями чи маршрутами розширення.
- Посилання в toast-ах розширень не підтримуються.
Далі: AI-асистенти.