detain / phlix-plugin-example
Hello-world metadata provider — reference plugin for Phlix authors.
Package info
github.com/detain/phlix-plugin-example
Type:phlix-plugin
pkg:composer/detain/phlix-plugin-example
Requires
- php: >=8.3
- detain/phlix-shared: ^0.21.0
- psr/container: ^2.0
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.0
- squizlabs/php_codesniffer: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-03 22:05:49 UTC
README
Reference metadata-provider plugin for Phlix — the smallest plugin that exercises the full Phase A loader lifecycle.
This repo is the canonical hello-world template for Phlix plugin
authors. It does almost nothing on purpose: it implements the
Phlix\Shared\Plugin\LifecycleInterface contract, returns a fixed
greeting when its lookup() method is asked about one well-known
fixture path, and ships with a CI workflow plus PHPUnit tests so you
can fork it as a starter. Read "What it does" below before you copy
anything — the demo method is deliberately not wired to a live host
call path, and the live call paths are named there.
What it does
Calling HelloMetadataProvider::lookup('/test/hello.mkv') returns:
['title' => 'Hello, World']
Any other path returns an empty array.
Read this before you copy the class.
lookup()is a didactic method: the host never calls it. There is no plugin-entrylookup()call site anywhere inphlix-server/src, and the loader only requiresLifecycleInterfaceto enable a plugin — so a clone that keeps this shape enables cleanly and does nothing forever. The contracts the host actually consults are:
- Metadata sources — implement
Phlix\Shared\Metadata\MetadataSourceInterface(sourceName(),supportedMediaTypes(),search(),getDetails(),getImages()).PluginLoader::enable()registers an entry instance into the metadataSourceRegistryonly when it isinstanceofthat interface.- Settings delivery — implement
Phlix\Shared\Plugin\ConfigurableInterface(configure(array $settings)). Per the loader's own words this is "the ONLY channel by which a plugin receives its configured API keys/options".This reference class implements neither, which is why the
greetingsetting is stored but never delivered: an operator's override round-trips into thepluginstable while the instance keeps its constructor default. The live metadata plugins (e.g.phlix-plugin-omdb) show the real arms; the developer guide tours the contracts.
Install
The plugin is unsigned by design (signature: null) — the host logs a
warning and proceeds, because the trusted-key allowlist ships empty.
Install into a running Phlix server:
-
Log in as an admin user (
users.is_admin = 1). -
Open the admin Plugins section at
/app/admin/plugins(the legacy/admin/pluginspath redirects there). -
Use the Install from URL panel. The installer accepts three flavours: a repository URL (rewritten to its default-branch tarball by
SourceUrlResolver), a direct archive URL (*.zip,*.tar.gz,*.tgz), or a stubplugin.jsonwhosesourcefield points at such an archive.⚠ A GitHub URL paste does not complete on a current server — verified 2026-10-29 against
detain/phlix-plugin-exampleat every shape. Everyhttps://github.com/OWNER/REPO/archive/…URL answers302, and the installer refuses any redirect before extraction (R20-02 SSRF gate): the paste fails withHTTP redirects are not allowed (received HTTP/1.1 302 Found). The recipe this README used to print — paste theraw.githubusercontent.com/.../main/plugin.jsonURL — fails twice over: the branch ismaster(that URL 404s), and even corrected, this manifest has nosourcefield, so the stub arm throwsStub plugin.json must contain a "source" field pointing at a tarball or zip.A pinned, digest-verified remote fetch is catalog-driven; the server-side hop that would let a GitHub paste reachcodeloaddirectly is not shipped yet. What works today:-
Local archive (the procedure verified in this fix's walk): from one directory above your checkout, package it (a single root directory is flattened automatically):
tar czf /tmp/phlix-plugin-example.tar.gz \ --exclude='*/vendor' --exclude='*/.git' phlix-plugin-examplepaste
file:///tmp/phlix-plugin-example.tar.gz, or run the same install from the server's CLI (from thephlix-serverinstall directory):php bin/phlix plugin:install file:///tmp/phlix-plugin-example.tar.gz
file://sources are exempt from both the HTTPS-only gate and the pin requirement below — they are operator-local bytes. -
A remote archive that answers
200with no redirect (e.g. a self-hosted artifact download ending in.tar.gz/.zip), or a stubplugin.jsonwhosesourcenames one. Remote, un-pinned sources are refused by the loader's default-deny (SV-S2b):Refusing to install unverified plugin source …— unless the source matches a catalog entry carryingref+artifactSha256(this repo is listed in the officialdetain/phlix-pluginscatalog), or the operator setsPHLIX_PLUGINS_ALLOW_UNVERIFIED=1as an escape hatch (logs a warning). A catalog install click still resolves to agithub.com/.../archive/...fetch, so the curated path hits the same redirect wall until a server-side hop ships. HTTPS-only applies unlessPHLIX_PLUGINS_ALLOW_HTTP=1(the admin endpoint stays HTTPS/file://-only regardless).
-
-
The server downloads (or copies) the archive, validates the manifest with
Phlix\Plugins\Manifest\ManifestSchemaagainst the JSON Schema bundled indetain/phlix-shared, checksphlix_min_server_version, verifies any signature, runscomposer install --no-dev(with--no-scripts --no-plugins— the host's RCE kill-switch for install hooks), and stores a row in thepluginstable. The plugin lands disabled by default. -
Flip the toggle in the table to enable it.
The same operations are reachable via the JSON API —
POST /api/v1/admin/plugins/install with body {"url": "..."}, then
POST /api/v1/admin/plugins/{name}/enable; see
docs/plugins/install-from-url.md
for the curl recipes, and
docs/plugins/install-from-catalog.md
for the curated one-click path.
Use
Because the host never calls lookup() (see "What it does"), the only
honest demo is the in-process one — exactly what
tests/HelloMetadataProviderTest.php exercises:
$provider = new \Phlix\PluginExample\HelloMetadataProvider(); $provider->lookup('/test/hello.mkv'); // ['title' => 'Hello, World'] $provider->lookup('/anything/else'); // []
The fixture path lives at
Phlix\PluginExample\HelloMetadataProvider::FIXTURE_PATH if you want
to reference it from tests.
Settings & secrets
A real fork outgrows the demo quickly — usually by adding an upstream API key (TMDB, TVDB, Fanart.tv, the classic metadata sources). This template makes no network call, so the rules it cannot demonstrate are stated here instead of being left to each cloner's improvisation:
- A secret has no place in the manifest.
plugin.jsonships in your public archive and is the bytes the installer fetches; adefaultcarrying a real key leaks it to every clone. Declare the key with"secret": trueand"default": null—required: trueis still fine, the host null-fills (see the Note in Fork as a starter step 2) — exactly the shapephlix-plugin-omdbuses for itsapi_key. "secret": trueis the blessed config seam. Only flagged keys are host-managed secrets: the admin API (GET /api/v1/admin/plugins/{name}) renders their stored values masked (***—Phlix\Plugins\SettingsMaskerserver-side), and the configure-form save keeps the stored secret when the UI round-trips the mask sentinel unchanged. A key declared without the flag round-trips visibly through admin surfaces — the flag, not the key's name, is what makes a setting a secret.- Read keys only through
configure().Phlix\Shared\Plugin\ConfigurableInterface::configure(array $settings)is, per the loader's own words, "the ONLY channel by which a plugin receives its configured API keys/options" (phlix-server/src/Plugins/PluginLoader.php). Stash it in a private property; never write the whole settings array — or any one secret value — into a log line. - Never let a raw secret reach an output surface, including
strings you return as
titleand URLs you print on failure. When the upstream demands the key in a query string, redact it on every log/exception surface —OmdbApi::MASKED_APIKEY+redactSecrets()inphlix-plugin-omdbare the reference.
Outbound calls
HelloMetadataProvider never touches the network; your replacement
probably will. The estate posture to copy — demonstrated by the live
metadata plugins, not by this demo:
- https, with no runtime exception. The installer is HTTPS-only
(SV-S2b — see Install above); mirroring that gate in your own
calls costs nothing: pin an
https://base URL as a constant (OmdbApi::API_BASE). - Explicit timeout, TLS verification on. Use the HTTP client the
host runtime already carries —
Workerman\Http\Client, constructed with'timeout' => …— and leaveverify_sslenabled.phlix-plugin-omdbdefaults verification on and exposes the opt-out as a deliberateuse_ssl_verificationsetting rather than silently disabling it. Never reach forfile_get_contents()or a raw socket without a timeout: a blocking call with no bound can stall the worker process your plugin shares with the host. - Bound your own retries.
OmdbApi::enforceRateLimit()shows a plugin staying inside its upstream's contract; retry storms are the plugin's problem, not the provider's fault budget.
Fork as a starter
This repository is intentionally small (one PHP class, two test files plus a bootstrap) so you can copy it as the seed for your own plugin:
-
Fork or
git clonethis repo, then rename the new directory. -
Edit
plugin.json— pick a newname(must start withphlix-plugin-), setversion, changeentryto your FQCN, and keepphlix_min_server_versionat what your code actually requires. Theeventsaliases you may declare there are validation metadata: the host fail-fast checks each againstPhlix\Shared\Plugin\EventNameMap's real families (phlix.playback.*,phlix.library.*,phlix.user.*— there is nophlix.auth.*family); what actually subscribes your listeners is your entry class'ssubscribedEvents(). The manifest schema is the JSON Schema bundled indetain/phlix-shared/schemas/manifest.schema.json; the key-by-key guide isdocs/plugins/manifest.md.Note: If you declare a setting
required:true, give it adefaultor the host will null-fill it — seedocs/plugins/manifest.md. Settings only ever reach your instance throughConfigurableInterface::configure(). -
Edit
composer.json— rename the package, change the PSR-4 prefix underautoload.psr-4. -
Rewrite
src/HelloMetadataProvider.phpwith your own implementation, keepingonEnable()cheap and doing the heavy work in the listener methods declared bysubscribedEvents(). Implement the interface your manifesttypeis actually consulted through (metadata providers:MetadataSourceInterface) — copying the demolookup()signature gives you a plugin that enables and does nothing. If the new class needs an API key or calls an upstream service, follow Settings & secrets and Outbound calls above — do not improvise key handling. -
Replace the tests in
tests/to match — but keeptests/SecurityAuditCheckTest.php,scripts/security-audit-check.phpand thecomposer-auditjob in.github/workflows/test.yml. That trio is the estate's whole-lock audit gate, and the guard test reddens your build if you neuter it. -
Publish so the install flow can fetch you: a direct-200 archive URL (or a stub
plugin.jsoncarrying asourcefield that names one), plus a catalog pin for remote sources — see Install above for why a plain GitHub URL paste stops at the redirect gate today.
The plugin developer guide has the full walkthrough, including the lifecycle diagram and the manifest event alias table:
docs/plugins/developer-guide.mddocs/plugins/manifest.mddocs/plugins/install-from-url.mddocs/plugins/trusted-plugin-list.md
Running the tests locally
composer install
vendor/bin/phpunit
composer phpstan
composer phpcs
php scripts/security-audit-check.php # same security gate CI runs
License
MIT — see LICENSE.