Skip to content

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.

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.json requires whity/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 and TenantContext for 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.
  1. Deploy-copy the plugin into plugins/ (dev files excluded — the repo’s .gitattributes export-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 HEAD from the plugin repo, which honors the repo’s .gitattributes export-ignore list and produces the same file set). The host’s plugins/.gitignore keeps the copy out of git.

  2. Run migrations — the runner discovers plugin migrations automatically and records them as plugin:<Name>:<Class>:

    php public/index.php migrate run

    The plugin’s grant migration attaches its permissions to the admin role, so the feature works without manual SQL.

  3. Regenerate the OpenAPI spec — this is a deploy step: the served public/openapi.json must 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:openapi

    Note: 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.json in 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.

  4. Restart the workers (docker compose up -d --force-recreate frankenphp or your deployment’s equivalent). Two reasons: outside APP_ENV=development plugins 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.

  1. Disable at runtime if needed: POST /api/plugins/{name}/disable (drops its routes/hooks/features immediately).
  2. 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 rollback reverts the single most recently applied migration across the WHOLE ledger; there is no per-plugin targeting. Check php public/index.php migrate status first: if the plugin’s plugin:<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’s down() is safe either way: it removes only its own marker-scoped permissions and never another role’s grants.)
  3. Delete plugins/<Name>/, regenerate the spec, restart workers.
  • php public/index.php migrate status shows the plugin’s migrations Executed.
  • GET /api/plugins lists the plugin active (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 (distinct JWT_SECRET/ENCRYPTION_KEY, ports) and a compose file with per-deployment container names/host ports (run with docker compose -p <app> up -d; db-init bootstraps 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 archive piped 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:

  1. 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 run to re-apply the sibling migrations that were popped along the way. THIS DESTROYS THE ROLLED-BACK SIBLINGS’ DATA: a sibling’s down() 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.
  2. 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.