PagibleAI CMS admin extensions let your Composer package add a protected sidebar page or an editor tab without changing the core admin code. This guide builds both extension types with PHP, Vite and Vue, reuses the host's supported Vuetify components and standard media picker, connects an authenticated GraphQL query, and publishes the compiled modules through Laravel.
Build PagibleAI CMS Admin Extensions with Vue
Compatibility: The examples target PagibleAI 0.13 and its matching aimeos/pagible-admin package. The host UI component contract, the CMS components, the confirm and pluginAside injections and package translation catalogs described below require that version or newer. Match all frontend dependency versions to the PagibleAI release installed in your application.
The finished package provides:
- A permission-protected Products page at
/cmsadmin/products. - A Commerce tab in the page editor.
- A host-styled list with search, status filtering, menus, actions and the standard media picker.
- Two same-origin Vue modules that share the admin's runtime, authenticated Apollo client and message queue.
How PagibleAI admin extensions work
An extension has two parts:
- PHP registration tells PagibleAI what to display, where to display it and which permission protects a sidebar panel.
- Vue modules render the panel or editor tab. Each file must be a pre-built ES module whose default export is a Vue component.
The admin sends registered definitions to the browser when it renders the application shell. Vue loads the component with import() the first time it is displayed. An import map connects the bare vue, vue-router, vuetify and graphql-tag imports to the admin's own runtime, so keep those four packages external in your build.
After loading the module, the host wraps its entry component and registers a documented set of common Vuetify components plus the CMS components CmsActionMenu, CmsDialog, CmsFilePicker and CmsLoadingSpinner. The components use the active admin theme and are not bundled again with every extension.
A component URL must begin with one /. Absolute URLs, protocol-relative //host/... URLs and paths containing .. are rejected. The admin Content Security Policy also limits scripts to the same origin.
Choose an admin extension type
productslabel, permission, componentpage:commercelabel, component- Use
[a-z0-9_-]+for a navigation-panel key. - Use
host:namefor a sub-panel, wherehostispage,elementorfile. - A navigation panel gets the route
/<key>below the admin base URL. - A sub-panel inherits access to its host editor and cannot declare a separate
permission.
Create the Composer package
This guide uses a package named acme/pagible-commerce. Keep the Composer name, PHP namespace and public asset directory consistent when you substitute your own names.
pagible-commerce/
├── composer.json
├── package.json
├── vite.config.js
├── src/
│ └── CommerceServiceProvider.php
├── resources/
│ └── js/
│ ├── Products.vue
│ └── PageCommerce.vue
└── dist/ # generated by npm run build
Register the package with Laravel
Save this manifest as composer.json. Its package-discovery entry tells Laravel which service provider to boot.
{
"name": "acme/pagible-commerce",
"description": "Commerce panels for PagibleAI CMS",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.2",
"aimeos/pagible-admin": "~0.13"
},
"autoload": {
"psr-4": {
"Acme\\Pagible\\Commerce\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"Acme\\Pagible\\Commerce\\CommerceServiceProvider"
]
}
}
}
Laravel package discovery boots CommerceServiceProvider; the application does not need to register it manually. Use a compatible package constraint when your application runs a newer PagibleAI release.
Register navigation panels and editor tabs
Create src/CommerceServiceProvider.php. This provider registers a translation catalog, one sidebar panel, one page-editor tab and the directory that will contain both compiled modules.
<?php
namespace Acme\Pagible\Commerce;
use Aimeos\Cms\Plugin;
use Illuminate\Support\ServiceProvider;
class CommerceServiceProvider extends ServiceProvider
{
public function boot(): void
{
$basedir = dirname(__DIR__);
Plugin::i18n('commerce', '/vendor/cms/extensions/commerce/i18n/{locale}.json');
Plugin::register('products', [
'label' => 'Products',
'i18n' => 'commerce',
'permission' => 'product:view',
'component' => '/vendor/cms/extensions/commerce/products.js',
'icon' => '<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 3h18v18H3z" /></svg>',
]);
Plugin::register('page:commerce', [
'label' => 'Commerce',
'i18n' => 'commerce',
'component' => '/vendor/cms/extensions/commerce/page-commerce.js',
]);
$this->publishes(
[$basedir . '/dist' => public_path('vendor/cms/extensions/commerce')],
'cms-extensions'
);
}
}
productscreates/cmsadmin/productsand a Products sidebar entry.product:viewcontrols both the sidebar entry and its route. Grant it only to roles that need the extension; Authorization and Permissions explains how roles expand into permissions.page:commercecreates a Commerce tab for anyone withpage:viewaccess.iconaccepts inline SVG. PagibleAI sanitizes it with DOMPurify before rendering.i18nnames the translation catalog registered withPlugin::i18n().
The host translates the labels of navigation panels and editor tabs alike. With an i18n key, the label is passed through $pgettext with that key as context, e.g. $pgettext('commerce', 'Products'); without one, it falls back to the core $gettext catalog. Translate every string inside the Vue components with the same context, as shown in Translate the extension.
Check the registration fields
labelcomponentpermissioniconi18nPlugin::i18n(); used as gettext context for the labelRegister each key once. Registering a key again with a different definition throws a LogicException; a missing field, unsupported key, unsafe component URL or an i18n key without a previously registered catalog throws an InvalidArgumentException while Laravel boots. Call Plugin::i18n() before the Plugin::register() calls that reference its key.
Build the Vue extension modules
Add the frontend toolchain at the package root with this package.json:
{
"private": true,
"type": "module",
"scripts": {
"build": "vite build"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0",
"graphql-tag": "^2.12",
"vite": "^8.0",
"vue": "^3.5",
"vue-router": "^5.0",
"vuetify": "^4.0"
}
}
Run npm install after saving the manifest. These versions match PagibleAI 0.13; update them together when the installed admin changes its runtime versions.
Configure Vite
Save the following configuration as vite.config.js. It builds both Vue files as named ES modules and leaves the four host libraries as bare imports for the admin import map.
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: {
products: 'resources/js/Products.vue',
'page-commerce': 'resources/js/PageCommerce.vue'
},
formats: ['es'],
fileName: (format, name) => `${name}.js`
},
rollupOptions: {
external: ['vue', 'vue-router', 'vuetify', 'graphql-tag']
}
}
})
Do not bundle your own Vue runtime. The shared vuetify module exposes composables such as useTheme() and useDisplay(). The host also registers the supported components listed below directly on each plugin entry component, so that component can use their template tags without importing them.
This is a deliberately bounded contract, not global registration of every Vuetify component. Application-shell components, Vuetify Labs components and heavy specialized widgets stay private. Local component registration is not inherited by nested child SFCs; register and maintain dependencies used inside those children yourself, and check the resulting bundle size.
Reuse the host admin UI
Use the following stable components directly in the registered entry SFC. Vue resolves PascalCase names such as VTextField from kebab-case tags such as <v-text-field>. The host owns their versions, theme and lazy chunks.
VAutocomplete, VCheckbox, VCheckboxBtn, VCombobox, VFileInput, VForm, VRadio, VRadioGroup, VSelect, VSwitch, VTextField, VTextareaVAlert, VAvatar, VBtn, VChip, VIcon, VImg, VProgressCircular, VProgressLinearVCard, VCardActions, VCardText, VCardTitle, VCol, VContainer, VDialog, VDivider, VRow, VSheet, VSpacerVList, VListGroup, VListItem, VListItemTitle, VListSubheader, VMenu, VPagination, VTab, VTable, VTabs, VTabsWindow, VTabsWindowItem, VToolbar, VToolbarTitle, VWindow, VWindowItemCmsActionMenu, CmsDialog, CmsFilePicker, CmsLoadingSpinnerThe CMS components are loaded only when rendered:
CmsActionMenuis the responsive action menu/dialog shell used by the core list views.CmsDialogis the standard dialog with header, content and actions.CmsFilePickeris the media dialog described below.CmsLoadingSpinneris the shared loading indicator.
The contract intentionally excludes shell components such as VApp, VAppBar, VMain and VNavigationDrawer, Vuetify Labs components, and heavier widgets such as data tables, date pickers and timelines. A plugin may keep its own locally registered components; the host adds the public surface without mutating the module export.
Use the standard media picker
CmsFilePicker opens the admin's existing media dialog and is loaded only when rendered. Its v-model is a Boolean open state. The optional filter prop accepts the GraphQL FileFilter shape, grid starts with the media grid, and @add receives the selected file record. The example limits results to JPEG, PNG and WebP files. Omit filter to show all media. The current user still needs the relevant file permission.
Access editor props and GraphQL
Editor props differ by host:
- A
page:...sub-panel receivesitemandassets. - An
element:...sub-panel receivesitemandassets. - A
file:...sub-panel receivesitemonly.
item is reactive and changes when the editor loads or switches records. For page and element tabs, assets is an object keyed by file ID.
The admin provides its authenticated Apollo client through inject('apollo') and its notification queue through inject('messages'). Save the next component as resources/js/PageCommerce.vue; it loads the current page and ignores a stale response if you switch records before the request finishes.
<script setup>
import { inject, ref, watch } from 'vue'
import gql from 'graphql-tag'
const props = defineProps({
item: { type: Object, required: true },
assets: { type: Object, default: () => ({}) }
})
const apollo = inject('apollo')
const error = ref('')
const loading = ref(false)
const page = ref(null)
let request = 0
const PAGE = gql`
query ExtensionPage($id: ID!) {
page(id: $id) {
id
title
path
}
}
`
watch(() => props.item?.id, async (id) => {
const current = ++request
page.value = null
error.value = ''
if (!id) return
loading.value = true
try {
const { data } = await apollo.query({
query: PAGE,
variables: { id },
fetchPolicy: 'network-only'
})
if (current === request) page.value = data.page
} catch (exception) {
if (current === request) error.value = exception.message
} finally {
if (current === request) loading.value = false
}
}, { immediate: true })
</script>
<template>
<section class="pa-4">
<p v-if="loading">{{ $pgettext('commerce', 'Loading page…') }}</p>
<p v-else-if="error" role="alert">{{ error }}</p>
<template v-else-if="page">
<p>{{ $pgettext('commerce', 'Current page:') }} {{ page.title }}</p>
<p>{{ $pgettext('commerce', 'Attached media:') }} {{ Object.keys(assets).length }}</p>
</template>
</section>
</template>
The GraphQL request uses the signed-in admin session. The server still checks page:view, rate limits the request and returns an authorization error when the current user lacks access. Translate every string rendered by your Vue component with $pgettext('commerce', …). See the PagibleAI GraphQL API for the available queries, mutations and permission requirements.
Use the shared host services
The admin provides these objects to every extension component through inject:
apolloconfirmawait confirm.purge([{ name, info }], hint) resolves to true if the user confirms; hint is optionalmessagesadd(message, type, timeout)pluginAsidenull in editor sub-panelsimport { mdiCheckCircleOutline, mdiPlaylistCheck } from '@mdi/js'
export default {
inject: {
confirm: {},
messages: {},
pluginAside: { default: null }
},
data() {
const defaults = { status: null }
return { filter: this.pluginAside?.(() => this.asideContent, defaults) ?? defaults }
},
computed: {
asideContent() {
return [{
key: 'status',
title: this.$pgettext('commerce', 'Status'),
items: [
{ title: this.$pgettext('commerce', 'All'), icon: mdiPlaylistCheck, value: { status: null } },
{ title: this.$pgettext('commerce', 'Active'), icon: mdiCheckCircleOutline, value: { status: true } }
]
}]
}
},
methods: {
async purge(product) {
if (!(await this.confirm.purge([{ name: product.name, info: product.id }]))) return
// delete the product via your own GraphQL mutation, then notify
this.messages.add(this.$pgettext('commerce', 'Product deleted'), 'success')
}
}
}
pluginAside(content, defaults) shows the filter sidebar together with its toggle button in the app bar. content is a function returning the filter groups, so translated labels stay up to date, and defaults holds the initial filter values used on reset. It returns the reactive filter, which the admin stores per user. Inject it with a null default and fall back to defaults, because editor sub-panels have no sidebar. The icons come from @mdi/js; add it as a dev dependency if you use them.
Translate the extension
Extensions keep their translations in their own package. The admin loads the catalog of the active language into its shared gettext instance without overwriting the core or other package contexts:
- Register a catalog URL with
Plugin::i18n('commerce', '/vendor/cms/extensions/commerce/i18n/{locale}.json'). The URL must be site-relative and contain exactly one{locale}placeholder. - Set
'i18n' => 'commerce'in every panel and tab definition. The host then translates the registered label with$pgettext('commerce', label). - Use the same key as gettext context for every string in your components, e.g.
$pgettext('commerce', 'Products').
Generate one JSON file per locale with vue3-gettext: add it as a dev dependency, create a gettext.config.mjs whose output uses splitJson: true and writes the JSON files to public/i18n, and run vue-gettext-extract to update your .po files. Change the build script to vue-gettext-compile && vite build; Vite copies public/i18n/ to dist/i18n/, so the publish step below ships the catalogs next to the modules.
If a catalog cannot be loaded, the admin logs “Failed to load plugin translations” in the browser console and shows the untranslated source strings.
Reference implementation: The optional Webhooks package ships its admin panel exactly this way. webhooks/admin has its own Vite library build that keeps vue and graphql-tag external, compiles its gettext catalogs into dist/i18n/{locale}.json next to WebhookList.js, and uses the apollo, confirm, messages and pluginAside injections. Its service provider registers the catalog and panel and publishes dist to public/vendor/cms/webhooks under the cms-admin tag, so php artisan vendor:publish --tag=cms-admin --force updates it together with the core admin.
Publish and verify the extension
Run these commands from the package root:
composer validate --strict
php -l src/CommerceServiceProvider.php
npm run build
A successful build creates these entry files:
dist/
├── products.js
├── page-commerce.js
└── i18n/ # translation catalogs, e.g. de.json
└── <locale>.json
Install the package in your Laravel application. A Composer path repository is convenient while both projects are on your machine:
composer config repositories.pagible-commerce path ../pagible-commerce
composer require acme/pagible-commerce:@dev
php artisan vendor:publish --tag=cms-extensions --force
php artisan optimize:clear
Publishing copies the two modules and the translation catalogs to public/vendor/cms/extensions/commerce/, matching the URLs registered in PHP. Rebuild and republish after every frontend change. Use --force only when you intend to replace the previously published files.
Verify the result
Check the complete path before you distribute the package:
- Sign in with a user who has
product:viewand open/cmsadmin/products. - Confirm the search field, status selector, list, chips, action menu and buttons use the active admin theme.
- Clear the search field and confirm the complete list returns without a render error.
- Open Choose image, select a file and confirm its name appears in the success message.
- Check the browser console for unresolved-component warnings; there should be none for the documented host components used by the entry SFC.
- Open any page editor and select the Commerce tab.
- Confirm that the tab displays the current page title and the attached-media count.
- In the browser Network panel, confirm that
products.jsandpage-commerce.jsreturn200from the same origin. - Inspect the GraphQL response. The first authenticated page request should have this shape:
{
"data": {
"page": {
"id": "<current-page-id>",
"title": "<current page title>",
"path": "<current-page-path>"
}
}
}
Repeat the navigation check with a user who lacks product:view. The sidebar entry must be absent, and direct access to /cmsadmin/products must be refused.
Secure the admin extension
- Install only extension code that you have written or audited. A module runs inside the admin with the signed-in user's session, shared Apollo client and message queue.
- Keep component URLs site-relative. The registry rejects external, protocol-relative and traversal paths.
- Grant navigation permissions narrowly. Hiding a panel is not server authorization; every GraphQL query and mutation still needs its own server-side permission.
- Treat the host UI surface as presentation only.
CmsFilePickerdoes not bypass file permissions, and every file or content operation remains server-authorized. - Treat SVG icons as untrusted input even though PagibleAI sanitizes them.
- Keep the admin Content Security Policy enabled.
Troubleshoot the admin extension
Common extension problems
Why does the extension module return 404?
Run npm run build, then php artisan vendor:publish --tag=cms-extensions --force. Confirm that the generated filename and the registered component URL match exactly.
Why does the admin report “Failed to load plugin”?
Confirm that the module is served from the same origin, returns JavaScript with a successful status and has a Vue component as its default export. Check the browser console for the underlying import error.
Why does the browser fail to resolve `vue` or `graphql-tag`?
Keep vue, vue-router, vuetify and graphql-tag in rollupOptions.external, and confirm that the application has published the matching PagibleAI admin build containing its import map.
Why is the sidebar panel missing?
Run composer dump-autoload, confirm Laravel discovers CommerceServiceProvider and check that the signed-in user has product:view.
Why is a documented Vuetify component unresolved?
The host UI contract requires PagibleAI 0.13 or newer and is registered locally on the plugin entry component. Use the exact documented component name. A nested child SFC does not inherit that local registration, so it must register its own dependencies.
Why is `CmsFilePicker` unresolved or empty?
Use it directly in the registered entry component on PagibleAI 0.13 or newer. The signed-in user also needs permission to view files, and an optional filter must match the GraphQL FileFilter shape.
Why does the GraphQL request return an authorization error?
The shared Apollo client uses the current admin session, but the server still checks the permission required by each query or mutation. Grant the required CMS permission or use an operation the role may access.
Why do frontend changes not appear?
Rebuild the module, republish the assets with --force, run php artisan optimize:clear and hard-refresh the admin.
Why is the panel or tab label not translated?
Navigation panel and editor tab labels are translated with $pgettext using the definition's i18n key as context. Register the catalog with Plugin::i18n(), set the same key as i18n in the definition and use it as context in your components. Confirm that /vendor/cms/extensions/commerce/i18n/<locale>.json returns 200 and contains the label; failed catalogs are logged as “Failed to load plugin translations” in the browser console.
Source references
Current implementation references: PHP extension registry, lazy Vue module loader, host UI component contract, package translation loader, shared-runtime import map, Apollo, confirm and message-store injection, standard media dialog and the Webhooks admin extension.