Build PagibleAI CMS Admin Extensions with Vue

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.

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

Type
Key format
Location
Required fields
Navigation panel
products
Sidebar entry and its own admin route
label, permission, component
Editor sub-panel
page:commerce
Tab in the page, element or file editor
label, component
  • Use [a-z0-9_-]+ for a navigation-panel key.
  • Use host:name for a sub-panel, where host is page, element or file.
  • 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'
        );
    }
}
  • products creates /cmsadmin/products and a Products sidebar entry.
  • product:view controls 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:commerce creates a Commerce tab for anyone with page:view access.
  • icon accepts inline SVG. PagibleAI sanitizes it with DOMPurify before rendering.
  • i18n names the translation catalog registered with Plugin::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

Field
Required
Used by
Description
label
yes
both
Text shown in the sidebar or editor tab
component
yes
both
Site-relative URL of the compiled ES module
permission
yes
navigation panels
Permission required by the sidebar and route
icon
no
navigation panels
Inline SVG displayed beside the label
i18n
no
both
Key of a catalog registered with Plugin::i18n(); used as gettext context for the label

Register 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.

Group
Public components
Forms
VAutocomplete, VCheckbox, VCheckboxBtn, VCombobox, VFileInput, VForm, VRadio, VRadioGroup, VSelect, VSwitch, VTextField, VTextarea
Feedback and actions
VAlert, VAvatar, VBtn, VChip, VIcon, VImg, VProgressCircular, VProgressLinear
Layout and cards
VCard, VCardActions, VCardText, VCardTitle, VCol, VContainer, VDialog, VDivider, VRow, VSheet, VSpacer
Lists and navigation
VList, VListGroup, VListItem, VListItemTitle, VListSubheader, VMenu, VPagination, VTab, VTable, VTabs, VTabsWindow, VTabsWindowItem, VToolbar, VToolbarTitle, VWindow, VWindowItem
CMS
CmsActionMenu, CmsDialog, CmsFilePicker, CmsLoadingSpinner

The CMS components are loaded only when rendered:

  • CmsActionMenu is the responsive action menu/dialog shell used by the core list views.
  • CmsDialog is the standard dialog with header, content and actions.
  • CmsFilePicker is the media dialog described below.
  • CmsLoadingSpinner is 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.

Create the navigation panel

A navigation component receives its registered definition as panel. Save this self-contained entry component as resources/js/Products.vue. Its in-memory rows keep the example focused on the host UI contract; replace them with your own GraphQL data.

<script setup>
import { computed, ref } from 'vue'

defineProps({
  panel: { type: Object, required: true }
})

const search = ref('')
const status = ref(null)
const picker = ref(false)
const file = ref(null)
const selected = ref(null)
const products = ref([
  { id: 'starter', name: 'Starter', active: true },
  { id: 'enterprise', name: 'Enterprise', active: false }
])

const filtered = computed(() => {
  const term = (search.value ?? '').trim().toLocaleLowerCase()

  return products.value.filter((product) => {
    if (status.value !== null && product.active !== status.value) return false
    return !term || product.name.toLocaleLowerCase().includes(term)
  })
})

function choose(item) {
  file.value = item
  picker.value = false
}
</script>

<template>
  <v-container :aria-label="panel.label">
    <v-sheet class="pa-4">
      <v-row align="center">
        <v-col cols="12" md="6">
          <v-text-field
            v-model="search"
            :label="$pgettext('commerce', 'Search for')"
            variant="underlined"
            hide-details
            clearable
          />
        </v-col>
        <v-col cols="12" md="3">
          <v-select
            v-model="status"
            :items="[
              { title: $pgettext('commerce', 'All'), value: null },
              { title: $pgettext('commerce', 'Active'), value: true },
              { title: $pgettext('commerce', 'Inactive'), value: false }
            ]"
            :label="$pgettext('commerce', 'Status')"
            variant="underlined"
            hide-details
          />
        </v-col>
        <v-col cols="12" md="3" class="text-end">
          <v-btn color="primary" variant="tonal" @click="picker = true">
            {{ $pgettext('commerce', 'Choose image') }}
          </v-btn>
        </v-col>
      </v-row>

      <v-alert v-if="file" type="success" variant="tonal" class="my-4">
        {{ $pgettext('commerce', 'Selected file:') }} {{ file.name }}
      </v-alert>
      <v-alert v-if="selected" type="info" variant="tonal" class="my-4">
        {{ $pgettext('commerce', 'Selected product:') }} {{ selected.name }}
      </v-alert>

      <v-list v-if="filtered.length">
        <v-list-item v-for="product in filtered" :key="product.id">
          <v-list-item-title>{{ product.name }}</v-list-item-title>
          <template #append>
            <v-chip :color="product.active ? 'success' : undefined">
              {{ product.active ? $pgettext('commerce', 'Active') : $pgettext('commerce', 'Inactive') }}
            </v-chip>
            <v-menu>
              <template #activator="{ props }">
                <v-btn v-bind="props" variant="text">
                  {{ $pgettext('commerce', 'Actions') }}
                </v-btn>
              </template>
              <v-list>
                <v-list-item @click="selected = product">
                  <v-list-item-title>{{ $pgettext('commerce', 'Edit') }}</v-list-item-title>
                </v-list-item>
              </v-list>
            </v-menu>
          </template>
        </v-list-item>
      </v-list>
      <v-alert v-else type="info" variant="tonal" class="mt-4">
        {{ $pgettext('commerce', 'No products found') }}
      </v-alert>

      <CmsFilePicker
        v-model="picker"
        :filter="{ mime: ['image/jpeg', 'image/png', 'image/webp'] }"
        grid
        @add="choose"
      />
    </v-sheet>
  </v-container>
</template>

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 receives item and assets.
  • An element:... sub-panel receives item and assets.
  • A file:... sub-panel receives item only.

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:

Injection
Description
apollo
Shared, authenticated Apollo GraphQL client
confirm
Purge confirmation: await confirm.purge([{ name, info }], hint) resolves to true if the user confirms; hint is optional
messages
Snackbar message store: add(message, type, timeout)
pluginAside
Adds the filter sidebar of the core list views to a navigation panel; null in editor sub-panels
import { 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:

  1. 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.
  2. Set 'i18n' => 'commerce' in every panel and tab definition. The host then translates the registered label with $pgettext('commerce', label).
  3. 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:view and 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.js and page-commerce.js return 200 from 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. CmsFilePicker does 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