Plugins extend the CMS Framework with new content types, custom fields, admin
pages, nav entries, hooks, and — in hosts that ship a Module Federation runtime
— React admin components. This guide walks through every piece a plugin author
needs and points at the reference example under
examples/hello-world-plugin/.
- Plugin lifecycle
plugin.jsonmanifest schema- Base
PluginServiceProvider - Registering an admin page
- Registering nav entries
- Migrations
- Uninstalling
- Hook subscriptions
- Registering custom field types
- Injecting edit-screen panels & tabs
- Federated React modules
- Versioning & host compatibility
- Testing your plugin
Plugin lifecycle
- Discovery — The framework scans
base_path(config('cms.plugins.directory'))( defaultplugins/) for directories containing aplugin.jsonmanifest. - Install — When a plugin is uploaded,
PluginManager::installFromZip()extracts the archive, validates the manifest and inserts a row in thepluginstable. A plugin already on disk is registered withPluginManager::installFromDisk()instead. Since 2.8.0, the archive's declared uncompressed size is checked againstcms.plugins.maxUncompressedSize(100MB default) before extraction, as a zip-bomb guard; an archive that exceeds the ceiling is refused. Since 2.12.2, a ZIP whose slug is already taken — by apluginsrow or by any entry of that name in the plugins directory, compared case-insensitively — is refused withPluginInstallationException::alreadyInstalled()before anything is extracted, so an existing plugin's files are never overwritten. To ship a new version of an installed plugin, use the update flow (see Shipping updates). - Activate — On activation, the plugin's
service_provider( fromplugin.json) is registered with the container. This runs yourregister()andboot()methods, and any manifest-declared migrations. - Deactivate — The plugin row is marked inactive. The service provider is unregistered on the next request boot.
- Delete —
PluginManager::delete()deactivates the plugin if needed, firesap.cmsFramework.plugin.deleting, runs the manifest'suninstallclass ( 2.13.0, see Uninstalling ), resets the plugin's migrations whenrollback_migrations_on_deleteis set, removes the permissions it seeded, then removes its DB record and directory and firesap.cmsFramework.plugin.deleted.
plugin.json manifest schema
A plugin's root directory MUST contain a plugin.json file:
{
"$schema": "https://artisanpack-ui.dev/schemas/plugin.json",
"slug": "hello-world",
"name": "Hello World",
"version": "1.0.0",
"description": "Reference plugin demonstrating the framework's plugin API.",
"author": {
"name": "Jacob Martella",
"email": "me@jacobmartella.com",
"url": "https://jacobmartella.com"
},
"homepage": "https://example.com/hello-world",
"license": "MIT",
"service_provider": "HelloWorld\\HelloWorldServiceProvider",
"requires": {
"cms-framework": "^2.4",
"php": "^8.2",
"plugins": {
"google-oauth": "^1.0"
}
},
"conflicts": {
"legacy-hello": "*"
},
"autoload": {
"psr-4": {
"HelloWorld\\": "src/"
}
},
"migrations_path": "database/migrations",
"rollback_migrations_on_delete": false,
"uninstall": "HelloWorld\\Uninstall",
"nav_entries": [
{
"slug": "hello-world",
"label": "Hello World",
"url": "/admin/hello-world",
"icon": "fas.puzzle-piece",
"permission": "access_admin_dashboard",
"order": 60
}
],
"federated_module": {
"name": "helloWorldAdmin",
"entry": "dist/remoteEntry.js",
"exposes": ["./HelloWorldPanel"]
}
}
Declaring nav_entries and federated_module in the manifest is enough — the
framework bridges them into the runtime PluginRegistry when your plugin loads,
with no register*() call required. See
Registering nav entries and
Federated React modules.
Required fields
| Field | Type | Notes |
|---|---|---|
slug |
string | Lowercase, dashes; matches directory name. |
name |
string | Human-readable name for the admin UI. |
version |
string | Semver. Used for host-compatibility checks and update flows. |
service_provider |
string | Fully-qualified Laravel service provider class ( see below ). |
Optional fields
| Field | Type | Notes |
|---|---|---|
description |
string | Shown in the admin plugin list. |
author |
object|string | { name, email?, url? }, or a plain name string. |
homepage |
string ( URL ) | Documentation or marketing URL. |
license |
string | SPDX identifier ( MIT, Apache-2.0, etc. ). |
min_framework_version |
string ( semver ) | 2.14.0. Minimum cms-framework package version, e.g. "2.14.0". Not the host application's version; use min_app_version for that. Enforced on activation. See Versioning & host compatibility. |
min_host_version |
string ( semver ) | Deprecated since 2.14.0: alias of min_framework_version. Despite the name, it is compared against the cms-framework version, not the host application's. |
min_app_version |
string ( semver ) | 2.14.0. Minimum version of the host application ( e.g. Keystone CMS ), compared against the host's cms.plugins.appVersion config. Enforced on activation. See Versioning & host compatibility. |
requires |
object | Semver constraints: { "cms-framework": "^2.4", "php": "^8.2" }. A nested plugins object declares plugin-to-plugin dependencies. Enforced on activation. See Plugin dependencies & conflicts. |
conflicts |
object | Map of plugin slug to version constraint. Activation is refused when a matching plugin is installed. See Plugin dependencies & conflicts. |
composer |
object | Map of Composer package name to version constraint, e.g. { "artisanpack-ui/convertkit": "^1.2" }. Resolved from Packagist on activation. See Composer-package dependencies. |
autoload |
object | PSR-4 map. The framework hands this to Composer's runtime ClassLoader. Since 2.13.0 each namespace must end in a backslash ( "HelloWorld\\" ) and each path must be a relative string inside the plugin ( no .., no absolute paths ); anything else is refused at install and update. |
migrations_path |
string ( path ) | Relative path to your migrations directory. Auto-run on activate. See Migrations. |
rollback_migrations_on_delete |
boolean | Default false. When true, deleting the plugin reverts every migration under migrations_path. See Migrations. |
uninstall |
string ( class ) | 2.13.0. Invokable class run on delete, even when the plugin is inactive. See Uninstalling. |
federated_module |
object | Single federated module descriptor { name?, entry, exposes?, boot_module? }. entry is an https URL ( plain http only for a localhost dev server ) or a path inside a subdirectory of the plugin such as dist/remoteEntry.js, which the framework serves. exposes is a list of module ids. boot_module ( 2.14.0 ) is an exposed module the host preloads. Unknown keys are logged. Auto-registered on load. See Federated React modules. |
nav_entries |
array | List of static nav entries { slug, label, url?, icon?, permission?, order?, ... }. Auto-registered on load; equivalent to calling registerNavEntry() from your provider. |
update |
object | Where self-updates come from. See Shipping updates. |
update_url |
string ( URL ) | Legacy custom JSON update feed. Superseded by update; still honored. |
Displaying the author.
PluginManager::discoverPlugins()and::getPlugin()returnauthorverbatim from the manifest, so it is a string or an object depending on whatplugin.jsondeclares — echoing it directly risks anArray to string conversion. Each returned plugin also carries anauthor_namekey, always a display-safe string ( the object'sname, the plain string, or''), which is what a host should render in a plugin list.
Base PluginServiceProvider
Extend
ArtisanPackUI\CMSFramework\Modules\Plugins\Support\PluginServiceProvider
for opinionated helpers:
<?php
namespace HelloWorld;
use ArtisanPackUI\CMSFramework\Modules\Plugins\Support\PluginServiceProvider;
final class HelloWorldServiceProvider extends PluginServiceProvider
{
public function register(): void
{
// Bind services here.
}
public function boot(): void
{
$this->registerAdminPage( 'hello-world', [
'title' => __( 'Hello World' ),
'section' => 'tools',
'view' => 'hello-world::admin.index',
'capability' => 'access_admin_dashboard',
'icon' => 'fas.puzzle-piece',
'order' => 60,
] );
$this->registerNavEntry( [
'slug' => 'hello-world',
'label' => __( 'Hello World' ),
'url' => '/admin/hello-world',
'icon' => 'fas.puzzle-piece',
'permission' => 'access_admin_dashboard',
'order' => 60,
] );
$this->loadViewsFrom( $this->pluginPath( 'resources/views' ), 'hello-world' );
}
}
Available helpers:
registerAdminPage( string $slug, array $config )— attach an admin page to the sharedAdminMenuManager.viewfor Blade,componentfor federated React.registerNavEntry( array $entry )— add an idempotent nav entry to the container-boundPluginRegistry.registerFederatedModule( string $name, string $entryPath, array $exposes = [], ?string $bootModule = null )— declare a Module Federation entry hosts can consume.$entryPathis an absolute URL, a root-relative URL on the host, or a path relative to the plugin directory (dist/remoteEntry.js), which the framework serves. Pass the relative path, notpluginPath(), which returns a filesystem path.$bootModuleis 2.14.0.pluginPath( ?string $subpath = null )— resolve a path within your plugin directory.pluginConfig( ?string $key = null )— read the plugin'splugin.json; dot-notation supported.pluginSlug()— resolves from your manifest.
Registering an admin page
Blade version:
$this->registerAdminPage( 'hello-world', [
'title' => __( 'Hello World' ),
'section' => 'tools',
'view' => 'hello-world::admin.index',
'capability' => 'access_admin_dashboard',
] );
The view value is a namespaced Blade view; register it with loadViewsFrom( $this->pluginPath('resources/views'), 'hello-world' ) in boot(). The
framework wraps it in a closure before it reaches the route, so you get a
rendered page rather than Laravel's Invalid route action error.
The admin layout
Extend cms::admin.layouts.app and fill its title and content sections:
@extends('cms::admin.layouts.app')
@section('title', __('Hello World'))
@section('content')
<h1>{{ __('Hello World') }}</h1>
@endsection
The layout is deliberately plain — the framework is front-end agnostic and
ships no CSS build. It renders the admin menu, yields your content, and
exposes styles and scripts stacks. Host apps replace it with their own
chrome by publishing it:
php artisan vendor:publish --tag=cms-views
That writes to resources/views/vendor/cms/, which Laravel resolves ahead of
the package's copy — so a host swapping in its own chrome does not require any
plugin to change the view it extends.
Federated ( React ) version — same helper, use component instead of view:
$this->registerAdminPage( 'hello-world', [
'title' => __( 'Hello World' ),
'section' => 'tools',
'component' => 'helloWorldAdmin/HelloWorldPanel',
] );
Host apps map the component identifier to a real React component through
their Module Federation loader.
The federated admin-page contract
The framework does not own the frontend and ships no Module Federation
runtime. It cannot resolve or hydrate a federated component itself — a host
application must do that. registerAdminPage( component: ) gives you a
host-agnostic mount contract; the host holds the hydration responsibility.
There are two supported ways a host can hydrate the mount:
1. Scan the Blade mount (host-agnostic). A component-only admin page
renders the framework-owned cms::admin.layouts.federated shell, which emits a
single mount point inside the admin chrome:
<div data-cms-federated-module="helloWorldAdmin/HelloWorldPanel">
<p class="cms-admin__federated-fallback" role="status">
This admin page loads the federated module "…", which the host
application must hydrate. …
</p>
</div>
A host whose front end scans for data-cms-federated-module mounts the named
module into that div, replacing the fallback notice. Until a host hydrates it,
the fallback notice renders in place of a blank page — so an unconfigured host
shows a clear "needs a host runtime" message rather than a silent dead div.
2. Bridge to the host's own runtime via the filter (Inertia and others).
A host that mounts components its own way — a different mount element, a
server-rendered island, an Inertia page — overrides the default through the
ap.cmsFramework.admin.federatedPageAction filter, which receives the default
route action, the component identifier and the page config, and returns its own
route action:
addFilter(
'ap.cmsFramework.admin.federatedPageAction',
function ( \Closure $default, string $component, array $config ): \Closure {
return static fn () => view( 'host::federated', ['module' => $component] );
},
);
Returning anything other than a closure falls back to the shipped shell.
Inertia-based hosts (e.g. Keystone CMS). Keystone's federation runtime is Inertia-page-based: it resolves page names like
plugins/<remote>/<page>from a manifest built off theap.plugins.federatedModulesfilter and mounts them through the Inertia page resolver — it does not scan for the Blade mountdata-cms-federated-module. On such a host, ship your admin UI as an Inertia page under theplugins/<remote>/<page>route (paired with afederated_moduledeclaration so the host can resolve the remote), and — if you also wantregisterAdminPage( component: )to reach that page — bridge it with the filter above. Without the filter override,registerAdminPage( component: )renders the fallback notice on an Inertia host, because nothing hydrates the Blade mount there.
The shell is never handed to Route::get() as a bare string (which Laravel
rejects as an invalid route action; because admin routes register from a
booted() callback, that once surfaced on every request, not just the
plugin's own page, taking the whole application down). A page that declares
neither a view nor a component responds 501 on its own route instead of
breaking route registration.
Registering nav entries
registerNavEntry() writes an entry to the framework's PluginRegistry; the
host's admin shell reads it via the ap.admin.menu filter. Entries are
identified by slug and are idempotent — repeated registrations from the same
plugin ( e.g. under Octane workers ) overwrite instead of accumulating.
You may alternatively pre-declare nav entries in plugin.json under
nav_entries. The framework bridges each declared entry into the registry
through the same registerNavEntry() path — including URL sanitization — when
the plugin loads, so declaration alone is enough. Programmatic registration is
preferred when nav visibility depends on per-request state ( feature flags,
tenant configuration, etc. ), and always wins: a manifest entry is skipped when
your provider has already registered the same slug.
Migrations
Set "migrations_path": "database/migrations" in your manifest to have the
framework run your migrations on activation. Prefer schema-only changes;
data seeding belongs in a separate seeder invoked via an install hook.
Key name. Earlier versions of this guide and of the hello-world example used
"migrations". The framework has only ever readmigrations_path; a manifest usingmigrationshad its migrations silently skipped.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create( 'hello_world_greetings', function ( Blueprint $table ): void {
$table->id();
$table->string( 'message' );
$table->timestamps();
} );
}
public function down(): void
{
Schema::dropIfExists( 'hello_world_greetings' );
}
};
Dropping your tables on delete
Plugin tables survive deletion by default, so reinstalling a plugin keeps its
data. Set "rollback_migrations_on_delete": true to have delete() revert
your migrations. Since 2.13.0 this runs migrate:reset scoped to your
migrations_path, so every migration the plugin ran is reverted, whatever
batch it ran in. Before 2.13.0 it ran migrate:rollback, which only reverted
the last global batch and left your tables behind once any other migration had
run.
Give your migration files unique names. Laravel's migrations table
records only each file's basename, not its path. If a plugin migration shares
a basename with a host or package migration, resetting the plugin runs your
down() and deletes that shared row, so the host migration looks un-run.
Prefix the descriptive part with your slug:
database/migrations/2026_01_01_000000_hello_world_create_greetings_table.php
Uninstalling
Since 2.13.0, a plugin can declare an invokable class that cleans up when it is deleted:
"uninstall": "HelloWorld\\Uninstall"
<?php
namespace HelloWorld;
use ArtisanPackUI\CMSFramework\Modules\Plugins\Models\Plugin;
final class Uninstall
{
public function __invoke( Plugin $plugin ): void
{
// Remove settings rows, cached tokens, files under storage/, etc.
}
}
Use it for anything your plugin keeps outside its own tables. Listening to
ap.cmsFramework.plugin.deleting is not enough: that hook only reaches a
plugin whose service provider booted in the same request, and plugins are
usually deactivated first and deleted later.
How it runs:
delete()registers your PSR-4 autoloader, resolves the class from the container ( so its constructor may type-hint dependencies ) and invokes it with thePluginmodel.- It runs whether or not the plugin is active, and whether or not files are
being deleted (
delete( $slug, false )). - It runs after the
deletinghook and before the migration reset, permission removal and file deletion, so your tables and files are still there. - A missing, non-invokable or throwing class is logged and never blocks the delete.
Constraints checked at install and update, and again at delete:
- The value must be a fully qualified, namespaced class name with no leading backslash.
- The class must live under one of your
autoload.psr-4namespaces.
Checked only at delete, when the class is loaded:
- The class file must exist and sit inside your plugin directory. A namespace
that overlaps a host namespace (
App\) or a PSR-4 path that climbs out of the plugin cannot reach host code.
Install does not check that the class file exists. A missing file passes install, then at delete the error is logged and cleanup is skipped, so ship the file and test your uninstaller before releasing.
Trust. Deleting a plugin runs its
uninstallclass even if the plugin was never activated. Installing, activating and deleting all require the samemanage-pluginsability, so only install plugins you trust.
Hook subscriptions
The framework's action / filter system ( artisanpack-ui/hooks ) is the
primary extension seam. Register callbacks in boot():
addAction( 'ap.contentTypes.created', function ( $contentType ) {
logger()->info( 'A content type was created: ' . $contentType->slug );
} );
addFilter( 'ap.admin.contentEdit.saveData', function ( array $data, array $context ): array {
if ( 'post' === $context['contentType'] ) {
$data['metadata']['reviewed_at'] = now()->toIso8601String();
}
return $data;
} );
Full hook reference: docs/Hooks-and-Events.md.
Registering custom field types
Plugins can extend the closed FieldType enum through the
CustomFieldTypeRegistry. Registered types become available in the admin
custom-field-type dropdown and validate on save.
apRegisterFieldType( 'map_picker', [
'label' => __( 'Map Picker' ),
'column_type' => 'string',
'validation_rules' => ['string', 'regex:/^-?\d+\.\d+,-?\d+\.\d+$/'],
'editor_component' => 'helloWorldAdmin/MapPickerEditor',
'renderer_component' => 'helloWorldAdmin/MapPickerRenderer',
'meta' => ['icon' => 'fas.map'],
] );
Filter-registered fields do not materialize a physical DB column. Their values
live in the content record's metadata JSON column — the framework's
HasCustomFields trait knows how to read and write them there.
Injecting edit-screen panels & tabs
Host apps that build a post/page edit screen ( such as Keystone CMS ) consult
the ContentEditExtensions manager to render plugin-supplied panels and tabs.
Register from your provider's boot():
addFilter( 'ap.admin.contentEdit.panels', function ( array $panels ): array {
$panels[] = [
'slug' => 'hello-world.seo',
'title' => __( 'SEO' ),
'component' => 'helloWorldAdmin/SeoPanel',
'contentTypes' => ['post', 'page'],
'order' => 20,
];
return $panels;
} );
Filters:
ap.admin.contentEdit.panels— sidebar/right-column panelsap.admin.contentEdit.tabs— tabs above the main editorap.admin.contentEdit.beforeEditor/afterEditor— blocks above/below the editorap.admin.contentEdit.saveData— pre-persistence transform of the save payload
Entry shape: { slug, title, component, order?, contentTypes?, capability? }.
contentTypes accepts ['*'] for "all" or an explicit list.
Federated React modules
The framework does not own the frontend, and does not ship a Module Federation runtime. Host apps ( e.g. Keystone CMS ) that support runtime-loaded React plugins consume federated modules the plugin has declared through:
- Manifest — a
federated_moduleobject inplugin.json. The framework auto-registers it on load, keyed by the descriptor's optionalname(falling back to the plugin slug). Declaration alone is enough. - Programmatic —
$this->registerFederatedModule( $name, $entry, $exposes, $bootModule )inboot(). A programmatic registration wins: a manifest module is skipped when the same name is already registered.
Hosts read the registered descriptors from the ap.plugins.federatedModules
filter, keyed by remote name: { entry, exposes?, bootModule? }. entry is
always a URL the browser can load ( see below ).
Declaring a module makes it resolvable by the host; it does not, by itself,
place it on a page. To surface a federated module as an admin page, pair the
declaration with registerAdminPage( component: ) and hydrate it per the
federated admin-page contract — either the
Blade mount or the filter bridge, depending on the host.
The entry
2.14.0. The manifest's entry must be one of:
- An absolute
httpsURL, e.g. a CDN:https://cdn.example.com/acme/remoteEntry.js. - A plain
httpURL to a loopback dev server (localhost,*.localhost,127.0.0.1,[::1]), e.g.http://localhost:5174/remoteEntry.jswhile runningvite. - A path inside a subdirectory of the plugin, e.g.
dist/remoteEntry.js. The framework serves it for you ( see Serving your bundle ). The file name may use letters, digits,.,_,@and-, and must not start with a dot..., a leading/, backslashes and query strings are refused.
URLs with credentials ( https://user:pass@… ) are refused. registerFederatedModule()
additionally accepts a root-relative URL on the host ( /build/remoteEntry.js ),
which is passed through unchanged. An absolute filesystem path inside the
plugin ( e.g. from pluginPath() ) is treated as the matching relative path, one
under public_path() as its root-relative URL, and any other filesystem path is
logged and not registered, so a server path never reaches the browser.
exposes is a list of the module ids your federation build exposes, e.g.
["./SettingsPage"], not the { "./SettingsPage": "./src/SettingsPage.tsx" }
map from the build config.
boot_module
2.14.0. boot_module names an exposed module the host loads before the first
plugin page mounts, typically ./boot, so the plugin can register its JS hooks
before the host emits its first action. It does not need to be listed in
exposes. The descriptor carries it as bootModule. See the hooks-js
"Plugin bootModule recipe" for the module itself.
Serving your bundle
2.14.0. When entry is a relative path, the framework publishes the entry's
directory on a public route, GET /plugins/{slug}/assets/{path}
( plugins.assets ), and registers the absolute URL, e.g.
https://example.com/plugins/acme/assets/remoteEntry.js. Your plugin needs no
asset controller of its own.
- Only active plugins are served. Everything else returns an empty
404. - Only the entry's own directory is published, which is why the entry must sit in a subdirectory: an entry at the plugin root would publish your source and manifest. A plugin publishes one asset directory.
- Files must resolve inside that directory ( symlinks that escape it are
refused ) and have one of these extensions:
js,mjs,css,map,json,wasm,svg,png,jpg,jpeg,gif,webp,avif,woff,woff2,ttf,otf. Dot-files and dot-directories (.vite/) are never served. - Files whose name carries a bundler content hash ( Vite's
index-BxQ3k9aZ.js, webpack'smain.3f9a1c2e.js) are cached asimmutablefor a year. The entry and every other file are sentno-cachewith an ETag and Last-Modified, so they revalidate and a plugin update is picked up on the next load. - Every file is sent with
X-Content-Type-Options: nosniff. SVGs are also sandboxed with a CSP and sent as attachments, so<img src>works but opening one directly cannot run script.
The route is public, like the theme asset route: a federation runtime fetches
the bundle before any admin page exists to gate it. Do not put secrets in your
build. Source maps ( .map ) are served if you ship them.
Host integration
-
Catch-all routes. The asset route registers after every provider has booted, after the host's
routes/web.php. A host with a GET catch-all ( e.g. CMS pages at/{path}) must exclude theplugins/prefix, or the catch-all answers first and every plugin bundle fails to load:Route::get( '/{path}', [PageController::class, 'show'] ) ->where( 'path', '^(?!plugins($|/)).+$' ); -
Plugins with their own asset route. Because the framework route loads last, a plugin that registers its own
/plugins/{slug}/assets/{path}route fromboot()keeps matching first. -
Opting out. A host that serves plugin assets itself can set
cms.plugins.assetRoute( envCMS_PLUGINS_ASSET_ROUTE) tofalse. A relative entry still resolves to/plugins/{slug}/assets/…, so the host's route must answer that URL.
An example Module Federation config, Vite build, and remoteEntry contract live
in the Keystone CMS docs. See
examples/hello-world-plugin/ for a stub
that pairs a Blade admin page with a federated_module declaration that
Keystone can consume.
Versioning & host compatibility
Semver your plugin. A plugin runs inside two versioned things, and the manifest can declare a minimum for each:
| Key | Compared against | Where the version comes from |
|---|---|---|
min_framework_version |
The cms-framework package | The framework's composer.json, then Composer's InstalledVersions. |
min_app_version |
The host application ( e.g. Keystone CMS ) | The host's cms.plugins.appVersion config. |
"min_framework_version": "2.14.0",
"min_app_version": "1.2.0"
min_host_versionis the minimum cms-framework version, not the host application's. It was renamed tomin_framework_versionin 2.14.0 and is still honored as a deprecated alias. A plugin that wrote"min_host_version": "1.0.0"meaning "Keystone 1.0" was checked against cms-framework 2.x, which always passes. Usemin_app_versionfor the host application.Hosts running cms-framework older than 2.14.0 read only
min_host_versionand ignoremin_framework_versionandmin_app_version. To keep a framework minimum enforced there, declaremin_host_versionwith the same value asmin_framework_version( or use arequires.cms-frameworkconstraint, below ). Declaring both keys with different values is refused at validation.
PluginManager::activate() checks both before any state changes and throws
IncompatiblePluginException when the host is older than required. Its
constraint property is framework or app. Over HTTP the activate and update
endpoints return 409 with code plugin_incompatible and the details:
{
"message": "Plugin 'google' requires host application version 1.2.0 or higher; host is running 1.1.0.",
"code": "plugin_incompatible",
"plugin": "google",
"constraint": "app",
"required_version": "1.2.0",
"host_version": "1.1.0"
}
The framework cannot detect the host application's version, so the host
supplies it, either through the CMS_APP_VERSION environment variable or from
a service provider's register() method:
public function register(): void
{
config( [ 'cms.plugins.appVersion' => config( 'keystone.version' ) ] );
}
Do not write 'appVersion' => config( 'keystone.version' ) in
config/cms/plugins.php: config files load in name order, so keystone.php
has not loaded yet and the value is null, which silently skips every
min_app_version check. ( config( 'app.version' ) happens to work there,
only because app sorts before cms. )
A check that cannot run is skipped, not failed. When the version to compare
against is unknown ( the host sets no appVersion, or cms-framework runs from a
path-repo checkout with no resolvable version ) or is not a comparable semver
( dev-main ), activation goes ahead. The framework logs a warning and fires
the ap.cmsFramework.plugin.versionCheckSkipped action so a host can surface
it in its admin UI:
addAction( 'ap.cmsFramework.plugin.versionCheckSkipped', function ( string $slug, array $details ): void {
// $details = [
// 'constraint' => 'app', // or 'framework'
// 'required_version' => '1.2.0',
// 'host_version' => null, // null when unresolved
// 'reason' => 'unresolved', // or 'not_semver'
// ];
} );
For a range rather than a minimum, declare a constraint under requires
instead. It is checked against the cms-framework version on activation, and an
unmet constraint fails with plugin_dependencies_unsatisfied ( see
Plugin dependencies & conflicts ):
"requires": {
"cms-framework": "^2.14",
"php": "^8.2"
}
Plugin dependencies & conflicts
Plugins can declare hard dependencies on, and conflicts with, other plugins.
Dependencies live under requires.plugins and conflicts under conflicts,
both mapping a plugin slug to a semver constraint (* matches any version):
"requires": {
"plugins": {
"google-oauth": "^1.0"
}
},
"conflicts": {
"legacy-forms": "*"
}
PluginManager::activate() gates on these before any state changes. Activation
is refused when a required plugin is missing (not installed), inactive
(installed but not activated), or fails its version constraint, and when a
declared conflict is installed within its constraint range. Conflicts are
enforced symmetrically — activation is also refused when an already-installed
plugin declares a conflict against the plugin being activated, so a conflict
cannot be bypassed by activation order. The API returns 409 with a
machine-readable code (plugin_dependencies_unsatisfied or plugin_conflict)
and the offending details.
Conflicts are scoped to installed, not active, plugins (Composer-style semantics). Two plugins that declare a conflict against each other cannot both be installed and activated: activation is refused while the other is present on disk, even if it is inactive. Resolve a mutual conflict by deleting one of the two plugins, not merely deactivating it.
Deactivation is guarded in the other direction: PluginManager::deactivate()
refuses to disable a plugin while active plugins still depend on it (API code
plugin_has_active_dependents). Deleting a plugin forces past this guard.
At boot, active plugins are registered dependencies-first, so a dependent's service provider never boots before the provider whose services it consumes.
Resolution helpers:
| Method | Purpose |
|---|---|
checkDependencies( $slug ): DependencyResult |
Missing / inactive / version-mismatch / conflict buckets for one plugin. |
getDependents( $slug ): array |
Slugs of installed plugins that require $slug. |
canDeactivate( $slug ): bool |
Whether disabling $slug would break an active dependent. |
getActivationOrder( array $slugs ): array |
Dependency-first ordering; throws CircularDependencyException on a cycle. |
The same data is exposed over HTTP: GET /api/v1/plugins/{slug}/dependencies,
GET /api/v1/plugins/{slug}/dependents, and
POST /api/v1/plugins/check-dependencies (body { "plugins": [ "a", "b" ] }),
which returns a resolved activation order alongside per-plugin status.
Composer-package dependencies
A plugin can depend on a real Packagist package instead of vendoring it inside
the ZIP. Declare the requirement in a composer block — Composer package name
to semver constraint:
{
"slug": "convertkit",
"name": "ConvertKit",
"version": "1.0.0",
"service_provider": "ConvertKit\\ConvertKitServiceProvider",
"composer": {
"artisanpack-ui/convertkit": "^1.2"
}
}
This is the sibling of requires.plugins (plugin→plugin): requires resolves
other plugins by slug, while composer resolves Packagist packages and can
bring their vendor tree into the host. Keys are validated as Composer package
names (vendor/package), and each constraint must be a bounded, stable
semver range: a bare *, a @dev stability flag, a dev-<branch> reference, or
a lower-bound-only range (>=1.0, >1.0) is rejected at validation time, since
an auto-installed dependency must not pull the newest, an arbitrary branch's, or
a future major's code at activation. Bounded ranges — ^1.2, ~2.0,
>=1.0 <2.0, and bounded wildcards such as 1.2.* — are fine. The composer
block is re-validated at activation, not only at install/update, so a
requirement that reached the row unvalidated still fails closed. A plugin also
may not name a package the host itself requires in its root composer.json — it
may bring in new packages, but never change the version of one the host owns.
When it runs. On activation, before the service provider boots, the
framework resolves the composer block against the host's installed packages
(reusing composer/semver). Requirements already satisfied are left untouched.
Installing. When a requirement is unmet and
cms.plugins.autoInstallComposerDependencies is true, the framework runs
composer require <package>:<constraint> in the host root, so the dependency is
added to the host's composer.json / composer.lock and survives a fresh
deploy. The freshly installed package's PSR-4 prefixes are registered on the
running class loader so its classes are autoloadable when the plugin boots in
that same request. Composer itself arbitrates any conflict with the host's
existing lock. This is off by default (false): auto-install fetches and
boots arbitrary third-party Packagist code (its service provider via
package:discover, and any eager autoload.files) in the host process, so it
is opt-in and every declared install is logged. Enable it only where you trust
every installed plugin's declared dependencies.
Package migrations (2.11.0). When activation freshly installs a package that
registers its own migrations (loadMigrationsFrom() in its service provider),
the framework runs migrate --force after the plugin's service provider
registers, so the package's tables exist before first use. A migration failure
fails the activation and rolls it back.
Failing closed. Resolution never half-activates a plugin. If a requirement
is unmet and auto-install is disabled (the default), or an install cannot
complete — Packagist unreachable, a composer.lock conflict, or a missing
composer binary — activation raises ComposerDependencyNotSatisfiedException
and the activation
transaction unwinds. Over HTTP the activate endpoint returns 409 with code
plugin_composer_dependencies_unsatisfied. On the update flow the same failure
restores the previous version through the backup-restore path.
Deactivation & removal. Packages a plugin brought in are left in place
on deactivate and delete. They are never auto-removed: the host or another
plugin may share the same package, and Composer offers no safe ref-counted
uninstall here. Remove an unwanted package with composer remove yourself.
Hardened hosts. Set PLUGINS_AUTO_INSTALL_COMPOSER_DEPENDENCIES=false to
forbid runtime Composer installs; activation then requires the operator to have
installed the declared packages ahead of time. PLUGINS_COMPOSER_BINARY
overrides the resolved composer command for sandboxed PHP-FPM pools.
Shipping updates
Declare an update source in plugin.json. Publishing a new version is then
git tag plus a GitHub Release — no hosted JSON feed, no manual ZIP upload:
"update": {
"github": "ArtisanPack-UI/artisanpack-ui-plugin"
}
update accepts either form:
| Key | Value | Notes |
|---|---|---|
github |
owner/repo, or a full https://github.com/owner/repo URL |
Shorthand for the GitHub Releases source. |
url |
Absolute https:// URL |
Handed to the source detector as-is, so GitLab repository URLs and custom JSON endpoints work through the same key. |
Both forms are https-only: the resolved archive is extracted into your
plugins/ directory and its PHP is executed by the host.
UpdateManager walks your releases, skips prereleases, and picks the first
release asset ending in .zip — falling back to GitHub's generated
zipball_url when the release has no attached asset. Attach a real ZIP.
The generated zipball's root directory is named for the repository and commit,
not for your plugin slug, so extraction lands the plugin in the wrong
directory.
Checksums
The updater verifies the downloaded archive against a SHA-256 digest, resolved from either:
- a release asset named
{your-asset}.zip.sha256, or - a
SHA-256: <64 hex chars>line in the release description.
With the shipped defaults ( cms.updates.verify_checksum = true,
cms.updates.allow_unverified_updates = false ) a release that publishes
neither is refused. Add a sidecar step to your release workflow:
sha256sum my-plugin.zip | cut -d' ' -f1 > my-plugin.zip.sha256
This is an integrity check, not an authenticity check — the digest comes from the same release as the archive. It catches truncation and CDN corruption; it does not defend against a compromised release-editor account.
Since 2.8.0, legacy update_url feeds are held to the same bar. The feed's
download_url must be https — an http URL is refused up front, because it
would let a network attacker choose the archive that gets extracted and its
provider re-registered (arbitrary PHP execution) — and the downloaded archive
runs through the same checksum gate as the source-backed path. A feed that
advertises no digest is therefore refused with the shipped defaults, exactly
like a source-backed release; the only escape is
cms.updates.allow_unverified_updates. Add a sha256 to your feed payload to
clear the gate.
Private repositories
Public repositories need no credentials. For a private one, the host adds a token keyed by your plugin slug:
// config/cms.php
'plugins' => [
'updateTokens' => [
'my-private-plugin' => env( 'MY_PRIVATE_PLUGIN_UPDATE_TOKEN' ),
],
],
Tokens live in host config, never in plugin.json — the manifest ships inside
the distributed ZIP. They are per-slug rather than one global token: a plugin
names its own update host in its own manifest, so a shared token would be handed
to whatever host any installed plugin asks for. A site can therefore mix
sources freely — some plugins from a licensing server, others straight from a
private GitLab project — with each credential going only to its own plugin's
source.
A string token is sent by GitHub and GitLab sources as their own auth header,
and by a custom JSON feed as the ?token= query parameter.
Header auth for custom JSON feeds (2.12.0)
A query-string credential ends up in URLs, access logs and proxy logs. To send a
custom JSON feed's credential as a header instead, the host gives an array with
a headers key:
// config/cms.php
'plugins' => [
'updateTokens' => [
'my-licensed-plugin' => [
'headers' => [
'Authorization' => 'Bearer ' . env( 'MY_LICENSE_KEY' ),
],
],
],
],
The headers are sent with the feed request and with the archive download, so
your feed's download_url can point at an authenticated endpoint rather than a
pre-signed or public URL. They are scoped to the feed's own origin ( scheme,
host and port ): if download_url, or a redirect from it, names a different
host, the headers are dropped for that request. Serve the archive from the
feed's host, or keep using a pre-signed URL for an off-host one.
The feed URL itself must answer directly: redirects are not followed while credential headers are configured, so the credential cannot be passed along to another host.
An optional query key alongside headers carries any query parameters the
feed still needs. Header auth applies to manifests that declare the source
through update.url; the legacy update_url key is fetched without
credentials.
Scheduled update checks (2.12.0)
Update checks run on demand and are cached for cms.plugins.updateCacheTtl.
To keep those answers warm, the host schedules the built-in command:
// routes/console.php
Schedule::command( 'cms:plugins:check-updates' )->daily();
It re-checks every plugin regardless of source, bypassing the cached answers, and caches what it learns. One plugin's source failing does not stop the others from being checked; the command reports each failure and exits non-zero.
The command wraps UpdateManager::refreshUpdateChecks(), which a host can call
directly — for example from its own queued job:
use ArtisanPackUI\CMSFramework\Modules\Plugins\Managers\UpdateManager;
$result = app( UpdateManager::class )->refreshUpdateChecks();
$result['updates']; // array<string, array> — available updates, keyed by plugin slug
$result['failures']; // array<string, string> — failure messages, keyed by plugin slug
A failed check is reported in failures rather than thrown, and leaves that
plugin's previously cached answer in place.
Applying an update
UpdateManager::updatePlugin( $slug ) installs the latest available version
and returns false when there is nothing newer than the installed version:
use ArtisanPackUI\CMSFramework\Modules\Plugins\Managers\UpdateManager;
$updated = app( UpdateManager::class )->updatePlugin( 'hello-world' );
It fires ap.cmsFramework.plugin.updating, backs up the plugin directory,
deactivates the plugin if it was active, downloads and verifies the archive,
extracts it, re-validates the new plugin.json (its slug must match the
plugin being updated), saves the new version and manifest, reactivates the
plugin, and fires ap.cmsFramework.plugin.updated.
Rollback. If any step fails, the backup is restored and the plugins row
is reverted to the old version, manifest and active state, then a
PluginUpdateException is thrown. Since 2.13.0 this covers any Throwable,
not just exceptions, so an Error such as a TypeError from an updated
listener or the plugin's own provider also rolls the update back instead of
leaving it half-applied.
Framework caches (2.13.0). With cms.plugins.autoClearFrameworkCaches on,
a successful update runs route:clear, config:clear and view:clear, so
routes the new version adds or renames resolve under route:cache. The clear
runs after the updated hook. An active plugin is already cleared by the
deactivate / activate around the update, so the extra clear only runs for an
inactive one. See Configuration.
Testing your plugin
Plugins ship their own Pest / PHPUnit test suites. A minimal TestCase
extending Orchestra Testbench boots the framework in a testing container:
use ArtisanPackUI\CMSFramework\CMSFrameworkServiceProvider;
use HelloWorld\HelloWorldServiceProvider;
use Orchestra\Testbench\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
protected function getPackageProviders( $app ): array
{
return [
CMSFrameworkServiceProvider::class,
HelloWorldServiceProvider::class,
];
}
}
Test what matters: your admin page renders, your migrations run, your hook
subscriptions fire, and any custom field types round-trip through
HasCustomFields.
Reference plugin: examples/hello-world-plugin/.

