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, connects an authenticated GraphQL query, and publishes the compiled modules through Laravel.

Compatibility: The examples target PagibleAI 0.12 and its matching aimeos/pagible-admin package. Match the 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.
  • Two same-origin Vue modules that use the admin's runtime and authenticated Apollo client.

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.

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.12"
    },
    "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 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::register('products', [
            'label' => 'Products',
            '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',
            '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.

The host displays label exactly as registered; it does not pass registration labels through $gettext. Component text should use $gettext, as shown below.

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

Register each key once. A duplicate key throws a LogicException; a missing field, unsupported key or unsafe component URL throws an InvalidArgumentException while Laravel boots.

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.12; 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(), but the host does not guarantee that arbitrary <v-*> components are globally registered. The examples below use native HTML. If you import Vuetify components into your extension, include those components in your build and check the resulting bundle size.

Create the navigation panel

A navigation component receives its registered definition as panel. Save this component as resources/js/Products.vue.

<script setup>
defineProps({
  panel: { type: Object, required: true }
})
</script>

<template>
  <section class="pa-4">
    <h1>{{ panel.label }}</h1>
    <p>{{ $gettext('Manage your products here.') }}</p>
  </section>
</template>

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'). 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">{{ $gettext('Loading page…') }}</p>
    <p v-else-if="error" role="alert">{{ error }}</p>
    <template v-else-if="page">
      <p>{{ $gettext('Current page:') }} {{ page.title }}</p>
      <p>{{ $gettext('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 $gettext. See the PagibleAI GraphQL API for the available queries, mutations and permission requirements.

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

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 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 that Products appears in the sidebar and the page displays Manage your products here.
  • 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 or page-commerce.js returns 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 and shared Apollo client.
  • 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 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 `<v-btn>` or another Vuetify component unresolved?

The host does not guarantee global registration of arbitrary Vuetify components. Use native HTML or import and include the specific Vuetify components in your extension build.

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?

Registration labels are currently rendered as supplied and do not pass through $gettext. Use the admin language chosen for the installation and translate all text inside the Vue component with $gettext.

Source references

Verified snapshot: This guide was checked against PagibleAI commit 3c149a3 on 2026-08-02. The examples follow the PHP extension registry, lazy Vue module loader, extension route registration, Apollo client injection and shared-runtime build configuration.