Plugin Distribution
How a real (non-example) plugin is packaged, installed into a Whity host, and
removed. Established by the WC-170 pilot
(whity/plugin-announcements) — use that repository as the template.
Core principle: real plugins are never committed to whity-core. plugins/
is a runtime mount point — plugins/.gitignore keeps anything except the
reference plugins (HelloWorld/, ExamplePlugin.php) out of git, so an
installed plugin never dirties the host checkout. Product plugins live in
their own repositories.
Package anatomy
Section titled “Package anatomy”A distributable plugin is one Composer package in its own repository, with the
plugin code at the repo root (the loader maps plugins/<DirName> to the
<DirName>\ namespace, so the deploy-copy lands as plugins/Announcements/
containing AnnouncementsPlugin.php, Api/, Migrations/):
whity-plugin-announcements/ AnnouncementsPlugin.php # PluginInterface + PluginRequirementsInterface + PluginFrontendInterface Api/… # handlers (tenant-scoped, prepared statements) Migrations/… # MigrationInterface impls incl. the permission-grant migration composer.json # name whity/plugin-*, require whity/plugin-sdk ^1.2 — NEVER whity-core tests/, phpunit.xml, phpstan.neon, stubs/ # dev-only; export-ignored README.md- Depends on the SDK only.
composer.jsonrequireswhity/plugin-sdk(path repository for local dev, e.g."url": "../whity-core/sdk"). The two runtime host seams —\Whity\app(Database::class)for the PDO andTenantContextfor the tenant id — are not package dependencies: they are provided by the host at runtime and stubbed for the package’s own PHPStan (stubs/whity-host.stub.php). The package’s test suite runs against the SDK alone; that is the proof of independence. - Versioning: semver; declare
getSdkConstraint()(e.g.'^1.2') and, optionally,getCoreConstraint()(e.g.'^0.1', SDK 1.4) to pin the host CORE version too. The host’s version gate refuses incompatible plugins at load (quarantine) on either axis, so a plugin never half-loads against the wrong contract.
Installing into a host
Section titled “Installing into a host”-
Deploy-copy the plugin into
plugins/(dev files excluded — the repo’s.gitattributesexport-ignores them):Terminal window robocopy ..\whity-plugin-announcements plugins\Announcements /E `/XD tests vendor stubs .git .phpunit.cache `/XF phpunit.xml phpstan.neon .gitattributes .gitignore composer.lock(or
git archive HEADfrom the plugin repo, which honors the repo’s.gitattributesexport-ignore list and produces the same file set). The host’splugins/.gitignorekeeps the copy out of git. -
Run migrations — the runner discovers plugin migrations automatically and records them as
plugin:<Name>:<Class>:php public/index.php migrate runThe plugin’s grant migration attaches its permissions to the
adminrole, so the feature works without manual SQL. -
Regenerate the OpenAPI spec — this is a deploy step: the served
public/openapi.jsonmust describe the deployment’s actual route surface (core + installed plugins), because the schema-driven CRUD screens derive their columns and forms from it at runtime:php public/index.php generate:openapiNote: the spec file committed to whity-core is the core baseline (core routes + the reference plugins). The core test suite’s snapshot guard regenerates over a copy of the reference plugins only, so an installed real plugin does not fail it — but the plugin-inflated
public/openapi.jsonin your working tree is DEPLOYMENT state: never commit it. Restore it before committing core changes (git checkout -- public/openapi.json); the snapshot test will fail loudly on a dirty spec to remind you. -
Restart the workers (
docker compose up -d --force-recreate frankenphpor your deployment’s equivalent). Two reasons: outsideAPP_ENV=developmentplugins are deliberately not hot-loaded (WC-160), and the RBAC checker’s worker-level permission cache must pick up the migration’s new grants.
That’s the whole install: the plugin’s routes are live (RBAC-enforced via its
route-level requiredPermission), its screen appears in the sidebar
(descriptor-derived, /admin/x/<feature-id>), and the list/create/edit/delete
UI renders with zero per-app frontend code.
Uninstalling
Section titled “Uninstalling”- Disable at runtime if needed:
POST /api/plugins/{name}/disable(drops its routes/hooks/features immediately). - Roll back its migrations before removing the code (the runner needs the
migration classes). Caveat — rollback is global LIFO: each
php public/index.php migrate rollbackreverts the single most recently applied migration across the WHOLE ledger; there is no per-plugin targeting. Checkphp public/index.php migrate statusfirst: if the plugin’splugin:<Name>:*entries are the most recent, run rollback once per entry; if other migrations were applied after them, you CANNOT selectively roll the plugin back this way — your options (both with real costs, spelled out under “Uninstall in a fleet” below) are rollback-then-re-migrate, which DESTROYS the rolled-back siblings’ data, or accepting the schema remnants until a manual cleanup migration. (The grant migration’sdown()is safe either way: it removes only its own marker-scoped permissions and never another role’s grants.) - Delete
plugins/<Name>/, regenerate the spec, restart workers.
Sanity checklist after install
Section titled “Sanity checklist after install”php public/index.php migrate statusshows the plugin’s migrations Executed.GET /api/pluginslists the pluginactive(version gate passed).GET /api/frontend/features(as a permitted user) includes its descriptor.- Its screen renders at
/admin/x/<feature-id>and a denied user gets the empty feature list + 403 on the data API.
Extract once, consume twice: multi-deployment pattern (WC-171)
Section titled “Extract once, consume twice: multi-deployment pattern (WC-171)”The pilot proved the full lifecycle across a fleet of deployments (two separate downstream products — each its own clone of whity-core with its own compose project, database, secrets, and private plugins, plus the SAME shared plugin package):
Deployment anatomy. Each app is a checkout of whity-core with:
-
its own
.env(distinctJWT_SECRET/ENCRYPTION_KEY, ports) and a compose file with per-deployment container names/host ports (run withdocker compose -p <app> up -d;db-initbootstraps migrate+seed), -
its own private plugins dropped into
plugins/(e.g. a branding/config plugin from the app’s private repository), -
shared plugins installed from their repos’ release tags — from the plugin repo, in a POSIX shell (
git archivepiped through PowerShell corrupts the binary stream; Windows users take the robocopy variant above):Terminal window mkdir -p /path/to/host/plugins/<Name>git archive vX.Y.Z | tar -x -C /path/to/host/plugins/<Name>
Because the plugin lands in plugins/ BEFORE the first up, the
development bootstrap (db-init, which only runs when
APP_ENV=development) applies its migrations automatically. Staging and
production compose files have no db-init: there the install always includes
an explicit php public/index.php migrate run deploy step.
One web build, many backends. The Next.js server proxies resolve the
backend origin at RUNTIME from WHITY_BACKEND_URL (web/lib/backend-url.ts)
— set it per deployment when starting the web server. Do NOT rely on
NEXT_PUBLIC_API_URL for built output: Next inlines NEXT_PUBLIC_* values
at build time, freezing whatever the build machine had.
Staggered versions are normal. Each deployment upgrades on its own
cadence: re-deploy the new tag over plugins/<Name>/, migrate run (only
the new migrations apply; existing rows keep working), generate:openapi,
restart workers. The schema-driven screens pick up new fields with zero
frontend work — in the pilot, v1.1.0’s pinned flag appeared as a new
column and form checkbox in one deployment while the other ran a different
state entirely.
Uninstall in a fleet follows the per-deployment Uninstalling steps above, and the rollback LIFO caveat bites in practice: in the pilot, another plugin’s migrations sat on top of the stack. When that happens you have exactly two options, neither free:
- Rollback-then-re-migrate (what the pilot did): roll back every
migration down TO AND INCLUDING the target plugin’s, remove the target
plugin’s directory, then
migrate runto re-apply the sibling migrations that were popped along the way. THIS DESTROYS THE ROLLED-BACK SIBLINGS’ DATA: a sibling’sdown()typically drops its tables, and the re-migrate recreates them EMPTY. Acceptable only when the affected siblings’ data is disposable (fresh installs, demo data) or restored from a backup afterwards. - Leave the schema remnants: remove the plugin directory without rolling back; its tables and grants stay until a manual cleanup migration. Safe for data, untidy for schema.
For production fleets, neither is great — #194 (targeted per-migration rollback) tracks the real fix.