skills/pimcore-studio-bundle-setup/SKILL.md
Setting up a new Pimcore Studio bundle from scratch - boilerplate, frontend build, module federation, PHP configuration
npx skillsauth add pimcore/skills pimcore-studio-bundle-setupInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
How to create a new Pimcore Studio bundle from scratch:
Use this when:
your-bundle/
├── assets/ # Frontend assets
│ ├── js/
│ │ └── src/
│ │ ├── main.ts # Intentionally empty — webpack entry stub; plugins.ts is exposed directly by module federation
│ │ ├── plugins.ts # Plugin exports (real module federation entry point)
│ │ └── modules/ # Your feature modules
│ │ └── your-feature/
│ │ ├── index.ts # Plugin definition
│ │ └── modules/
│ │ └── your-feature-module.tsx
│ ├── package.json # NPM dependencies and scripts
│ ├── rsbuild.config.ts # Build configuration with module federation
│ └── tsconfig.json # TypeScript configuration
├── config/
│ └── services.yaml # Symfony service definitions
├── public/
│ └── build/ # Build output (git-ignored in dev)
├── src/
│ ├── PimcoreYourBundle.php # Bundle class
│ └── Webpack/
│ └── WebpackEntryPointProvider.php # Registers frontend entry points
└── composer.json # PHP dependencies
<?php
// src/PimcoreYourBundle.php
namespace Pimcore\Bundle\YourBundle;
use Pimcore\Extension\Bundle\AbstractPimcoreBundle;
class PimcoreYourBundle extends AbstractPimcoreBundle
{
public function getPath(): string
{
return \dirname(__DIR__);
}
}
{
"name": "pimcore/your-bundle",
"type": "pimcore-bundle",
"require": {
"pimcore/studio-ui-bundle": "^1.0",
"pimcore/studio-backend-bundle": "^1.0"
},
"autoload": {
"psr-4": {
"Pimcore\\Bundle\\YourBundle\\": "src/"
}
},
"extra": {
"pimcore": {
"bundles": [
"Pimcore\\Bundle\\YourBundle\\PimcoreYourBundle"
]
}
}
}
This PHP class tells Studio where to find the frontend build output:
<?php
// src/Webpack/WebpackEntryPointProvider.php
namespace Pimcore\Bundle\YourBundle\Webpack;
use Pimcore\Bundle\StudioUiBundle\Webpack\WebpackEntryPointProviderInterface;
final class WebpackEntryPointProvider implements WebpackEntryPointProviderInterface
{
public function getEntryPointsJsonLocations(): array
{
return glob(__DIR__ . '/../../public/build/*/entrypoints.json');
}
public function getEntryPoints(): array
{
return ['exposeRemote'];
}
public function getOptionalEntryPoints(): array
{
return [];
}
}
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
public: false
Pimcore\Bundle\YourBundle\Webpack\WebpackEntryPointProvider:
tags:
- { name: pimcore_studio_ui.webpack_entry_point_provider }
# Optional: also add this tag to support document editor iframe loading
# - { name: pimcore_studio_ui.webpack_entry_point_provider.document_editor_iframe }
The pimcore_studio_ui.webpack_entry_point_provider tag is required — it registers the frontend entry points with Studio core.
{
"name": "pimcore-your-bundle",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "NODE_ENV=development rsbuild build",
"dev-server": "NODE_ENV=dev-server rsbuild dev",
"build": "rsbuild build",
"lint": "eslint --ext .js,.jsx,.ts,.tsx ./js",
"lint-fix": "eslint --ext .js,.jsx,.ts,.tsx ./js --fix",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@pimcore/studio-ui-bundle": "<pin exact canary version — see Install Dependencies below>",
"react": "18.3.x",
"react-dom": "18.3.x",
"uuid": "^10.0.0"
},
"devDependencies": {
"@module-federation/rsbuild-plugin": "^0.13.1",
"@rsbuild/core": "^1.3.16",
"@rsbuild/plugin-react": "^1.3.1",
"typescript": "^5.3.3"
}
}
{
"compilerOptions": {
"module": "ESNext",
"target": "es2017",
"moduleResolution": "bundler",
"types": ["@types/webpack-env"],
"baseUrl": "./js/",
"strictNullChecks": true,
"jsx": "react",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"paths": {
"*": ["./@mf-types/*"]
}
},
"include": [
"./js/**/*",
"./@mf-types/*"
]
}
assets/js/src/main.ts is intentionally empty. rsbuild requires an entry file in source.entry; this stub satisfies that requirement. Module federation exposes plugins.ts directly via the exposes config — main.ts is never imported or executed.
// assets/js/src/main.ts
// Intentionally empty.
// rsbuild requires a source entry file; this stub satisfies that requirement.
// Module federation exposes plugins.ts directly via rsbuild.config.ts `exposes`.
// Do NOT add imports here.
This is the build configuration that enables your bundle to integrate with Studio via module federation:
// assets/rsbuild.config.ts
import { defineConfig } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { pluginModuleFederation } from '@module-federation/rsbuild-plugin'
import { pluginGenerateEntrypoints } from '@pimcore/studio-ui-bundle/rsbuild/plugins'
import { createDynamicRemote } from '@pimcore/studio-ui-bundle/rsbuild/utils'
import path from 'path'
import fs from 'fs'
import { v4 } from 'uuid'
import packages from './package.json'
const buildId = v4()
const buildPath = path.resolve(__dirname, '..', 'public', 'build', buildId)
// Clean previous builds
if (fs.existsSync(path.resolve(__dirname, '..', 'public', 'build'))) {
fs.readdirSync(path.resolve(__dirname, '..', 'public', 'build')).forEach((file) => {
if (file !== 'studio-npm-package.tgz') {
fs.rmSync(path.resolve(__dirname, '..', 'public', 'build', file), { recursive: true })
}
})
}
if (!fs.existsSync(buildPath)) {
fs.mkdirSync(buildPath, { recursive: true })
}
let nodeEnv = process.env.NODE_ENV
let env: 'development' | 'production' = 'production'
const isDevServer = nodeEnv === 'dev-server'
if (nodeEnv !== env) {
env = 'development'
}
export default defineConfig({
mode: env,
server: {
port: 3032, // Pick a unique port per bundle
},
dev: {
...(!isDevServer ? { assetPrefix: '/bundles/pimcoreyour/build/' + buildId } : {}),
client: {
host: 'localhost',
port: 3032,
protocol: 'ws'
}
},
source: {
entry: {
main: './js/src/main.ts'
},
decorators: {
version: 'legacy'
}
},
output: {
manifest: true,
assetPrefix: '/bundles/pimcoreyour/build/' + buildId,
distPath: {
root: buildPath
},
},
tools: {
bundlerChain: (chain, { env }) => {
chain.output.uniqueName('pimcore_your_bundle')
},
},
plugins: [
pluginGenerateEntrypoints(),
pluginReact(),
pluginModuleFederation({
name: 'pimcore_your_bundle',
filename: 'static/js/remoteEntry.js',
exposes: {
'.': './js/src/plugins.ts',
},
dts: false,
remotes: {
'@pimcore/studio-ui-bundle': createDynamicRemote('pimcore_studio_ui_bundle'),
},
shared: {
...packages.dependencies,
react: {
singleton: true,
eager: true,
requiredVersion: false,
},
'react-dom': {
singleton: true,
eager: true,
requiredVersion: false,
}
},
})
]
})
Deriving the right values from the bundle class name (e.g. PimcoreInspireCocktailDemoBundle):
| Value | Rule | Example |
|-------|------|---------|
| assetPrefix | Strip Pimcore + Bundle, lowercase everything, prefix /bundles/, suffix /build/ | /bundles/inspirecocktaildemo/build/ |
| uniqueName / MF name | Full class name without Pimcore prefix, snake_case | pimcore_inspire_cocktail_demo_bundle |
| server.port | Pick any free port above 3030 (studio core uses 3030) | 3032 |
The main entry point that Studio discovers and loads:
// assets/js/src/plugins.ts
import { type IAbstractPlugin } from '@pimcore/studio-ui-bundle'
export const YourPlugin: IAbstractPlugin = {
name: 'YourPlugin',
onInit ({ container }) {
// Register or override services in DI container
// container.bind('some-service', SomeService)
},
onStartup ({ moduleSystem }) {
// Register modules that configure the app
// moduleSystem.registerModule(YourModule)
}
}
Lifecycle:
onInit runs first — register NEW services or OVERRIDE existing onesonStartup runs after all services are ready — register modules that CONFIGURE services// assets/js/src/modules/your-feature/modules/your-feature-module.tsx
import { type AbstractModule, container } from '@pimcore/studio-ui-bundle'
import { serviceIds } from '@pimcore/studio-ui-bundle/app'
import { type MainNavRegistry } from '@pimcore/studio-ui-bundle/modules/app'
export const YourFeatureModule: AbstractModule = {
onInit: () => {
const mainNavRegistry = container.get<MainNavRegistry>(serviceIds.mainNavRegistry)
mainNavRegistry.registerMainNavItem({
path: 'Tools/Your Feature',
label: 'your-feature.title',
widgetConfig: {
name: 'Your Feature',
id: 'your-feature',
component: 'your-feature-widget',
config: {
translationKey: 'your-feature.title',
icon: { type: 'name', value: 'tool' }
}
}
})
}
}
// assets/js/src/modules/your-feature/index.ts
import { type IAbstractPlugin } from '@pimcore/studio-ui-bundle'
import { YourFeatureModule } from './modules/your-feature-module'
export const YourFeaturePlugin: IAbstractPlugin = {
name: 'YourFeaturePlugin',
onStartup ({ moduleSystem }) {
moduleSystem.registerModule(YourFeatureModule)
}
}
Then export from plugins.ts:
// assets/js/src/plugins.ts
import { YourFeaturePlugin } from './modules/your-feature'
export { YourFeaturePlugin }
@pimcore/studio-ui-bundle has no stable release yet — only canary versions. Before running npm install, check the latest canary and pin it exactly:
npm view @pimcore/studio-ui-bundle dist-tags
Update package.json with the exact version string from the canary tag (e.g. 1.0.0-canary.20260402-132451-02a210f), then:
# In the assets/ directory
npm install
# Development build (one-time, outputs to public/build/)
npm run dev
# Development server (hot reload, for active development)
npm run dev-server
# Production build
npm run build
# Type checking
npm run check-types
# Lint
npm run lint-fix
| Command | Use When | Output |
|---------|----------|--------|
| npm run dev | Quick build for testing | public/build/ |
| npm run dev-server | Active frontend development | Served from memory (port 3032) |
| npm run build | Production/deployment | public/build/ (optimized) |
When the dev server is running, Studio core automatically discovers it and loads your bundle's frontend from the dev server instead of the static build.
# DON'T - Missing tag
Pimcore\Bundle\YourBundle\Webpack\WebpackEntryPointProvider: ~
# DO - Include the tag
Pimcore\Bundle\YourBundle\Webpack\WebpackEntryPointProvider:
tags:
- { name: pimcore_studio_ui.webpack_entry_point_provider }
Without the tag, Studio won't discover your frontend bundle.
// DON'T - Wrong path format
assetPrefix: '/bundles/YourBundle/build/' + buildId
// DO - Lowercase, no separators
assetPrefix: '/bundles/pimcoreyour/build/' + buildId
Symfony serves bundle assets at /bundles/{lowercasebundlename}/. The prefix is the bundle name without "Bundle", all lowercase.
// DON'T - Use same port as another bundle or studio core (3030)
server: { port: 3030 }
// DO - Use unique port
server: { port: 3032 }
Studio core uses port 3030. Each bundle dev server needs its own port.
// DON'T - Forget the remote
pluginModuleFederation({
name: 'pimcore_your_bundle',
// Missing remotes!
})
// DO - Always include the studio-ui-bundle remote
pluginModuleFederation({
name: 'pimcore_your_bundle',
remotes: {
'@pimcore/studio-ui-bundle': createDynamicRemote('pimcore_studio_ui_bundle'),
},
})
// DON'T - Default export
export default YourPlugin
// DO - Named export
export { YourPlugin }
Module federation expects named exports from plugins.ts.
To set up a new Studio bundle:
AbstractPimcoreBundlecomposer.json with pimcore/studio-ui-bundle dependencyWebpackEntryPointProvider implementing WebpackEntryPointProviderInterfaceservices.yaml with pimcore_studio_ui.webpack_entry_point_provider tagassets/ directory with package.json, tsconfig.json, rsbuild.config.tsname, port, and assetPrefix in rsbuild configplugins.ts with named plugin exportsonInitnpm install and npm run devbin/console pimcore:bundle:list)public/build/After setting up the bundle:
tools
UX and UI design conventions for Pimcore Studio - layout, spacing, action labels, writing style, and design principles for consistent extensions
tools
Widget system in Pimcore Studio UI - registering widgets, opening them in layout areas, WidgetManagerTabConfig, and connecting widgets to navigation
tools
How bundles consume the Pimcore Studio UI SDK - plugins, modules, DI, registries, and imports
development
TypeScript coding standards and best practices for Pimcore Studio UI - type safety, null checks, and code quality