53 KiB
Feature Research
Domain: WinterCMS/Laravel-shaped content management framework (Go), headless-first, compiled plugins — v1 ports the Płytarium PHP backend so its Nuxt 4 app and MCP server run unchanged Researched: 2026-09-16 Confidence: HIGH for table stakes (grounded in Płytarium source), MEDIUM for differentiators (Go ecosystem + PocketBase/Directus/Strapi/Goravel comparisons), MEDIUM for anti-features (informed by WinterCMS structure, not exhaustively re-audited)
Method
Read plugins/golem15/fonoteka in full (Plugin.php, routes.php — 569 lines / ~160 routes, all 5 admin controllers and their config_form.yaml/config_list.yaml/config_relation.yaml, all 25 models, 3 jobs, 3 console commands, config/fonoteka.php), skimmed the user and websockets stack plugins for auth/realtime shape, skimmed golem (AI) plugin structure, and skimmed the Nuxt app's composables and the MCP server's client for what the frontend/agent actually calls. Cross-checked PocketBase/Directus/Strapi/Goravel via .planning/research/go-ecosystem.md and a web search for 2026-current comparisons.
Feature Landscape
Table Stakes (Płytarium does not run without these)
Grouped by category. Every row cites the concrete file(s) that depend on the capability.
Kernel / plugins
| Feature | Why Expected | Complexity | Notes |
|---|---|---|---|
| Plugin descriptor with register/boot lifecycle | Plugin.php registers config, console commands, mail templates/layouts, permissions, settings page, navigation, and wires cross-plugin event listeners in boot() |
MEDIUM | WinterCMS's register()/boot() split matters: register() runs first (config merge, service providers), boot() runs once every plugin is registered (safe to reference other plugins' classes). SummerCMS's descriptor must preserve this two-phase order — Fonoteka's boot() guards on class_exists for optional plugins (WebSockets, TokenScope) precisely because boot order across plugins isn't guaranteed. |
Plugin dependency declaration ($require) |
public $require = ['Golem15.Apparatus', 'Golem15.User'] |
LOW | Needed so build-time registration can order/validate plugin graphs. |
| Cross-plugin extension via events, not inheritance | registerNotificationListeners() hooks eloquent.created: Album; registerCollectionProvisioning() hooks golem15.user.register; surfaceOrganisationToApi() hooks golem15.user.getApiArray to add fields to the User plugin's serialized payload without editing User |
HIGH | This is the core "plugins extend each other" pattern PROJECT.md names. Needs (a) Eloquent-model lifecycle events (created, updated, deleting, etc.) fired generically by the ORM layer, (b) a named/string-keyed event bus plugins can both fire and listen on, (c) a "collect all listener return values into one array" halt=false semantics for the getApiArray pattern specifically (not just fire-and-forget pubsub). |
| Auth guard registration extension point | registerTokenGuard() registers a custom inv-token guard driver; user's JwtAuthGuardServiceProvider/JwtAuthGuard do the same for api |
HIGH | A plugin must be able to add a new authentication strategy (not just middleware) that other controllers' auth()->user()-equivalent resolves against. |
| Middleware aliasing/registration from a plugin | registerPasswordChangeGuard() aliases inv.must-change-password; TokenScope aliases inv.scope |
LOW | Route middleware needs to be a named, composable pipeline segment a plugin can register and other plugins' routes can reference by name. |
registerSchedule (cron-style recurring commands) |
Plugin::registerSchedule() runs fonoteka:prune-notifications daily |
LOW | Needs at minimum daily/interval scheduling tied to console commands — doesn't need a full cron DSL for v1. |
| Permissions registry + role-gated navigation | registerPermissions() (6 permissions, all gated to UserRole::CODE_DEVELOPER), registerNavigation() (top nav + 4-item side menu, each gated by its own permission) |
MEDIUM | Backend nav and permission declarations are structurally separate from frontend/API auth — a second, admin-only RBAC system. |
| Settings page registration | registerSettings() binds a Settings model to a backend settings screen (search_use_typesense switch) |
LOW | Single-row config model editable through the same fields.yaml pipeline as domain models. |
| Mail template/layout registration | registerMailTemplates() (6 templates incl. pl/en pairs), registerMailLayouts() |
LOW | Plugin-owned mail templates resolved by dotted name, per-locale suffix convention (-en). |
elevated plugin flag (privileged boot under test/console) |
public $elevated = true, with the explicit rationale that WinterCMS's PluginManager::$noInit skips non-elevated plugins' register()/boot() under PluginTestCase and privileged requests |
MEDIUM | A Go port needs an equivalent "does this plugin's register/boot run under the test harness and console bootstrap" concept, or the Go answer is simply "always run boot for every registered plugin" (simpler — flag this as a place the Go design can improve on Winter, see Differentiators). |
| Console command registration from a plugin | registerConsoleCommand('fonoteka.reindex', ReindexAlbums::class) etc., 3 commands |
LOW | Already required by PROJECT.md's command framework requirement. |
Optional-plugin dependency via class_exists guard |
WebSockets authorizer registration, TokenScope middleware alias, ai-scope routes all wrapped in existence checks so Fonoteka boots without WebSockets/Golem present | MEDIUM | A compiled-plugin Go system can't do a runtime class_exists — needs a build-time equivalent (e.g., a registered-plugins set checked at boot, or Go build tags) so a plugin can be optional to another without a hard import. |
Data / models
| Feature | Why Expected | Complexity | Notes |
|---|---|---|---|
belongsTo / hasMany / hasOne / belongsToMany (with pivot columns and a dedicated pivot model) |
Album (belongsTo Collection/Genre, hasMany ratings, hasOne reservation, belongsToMany styles/artists with pivot: ['sort_order']), Collection (belongsTo owner, hasMany albums, belongsToMany editors with pivotModel: CollectionEditor and pivot columns role, granted_at, granted_by), Artist/Style (belongsToMany albums with pivot sort_order) |
HIGH | GORM supports all four, but the named pivot model with extra pivot columns (CollectionEditor as a first-class model, not just an array) and ordered belongsToMany ('order' => 'name') need explicit design — this is not GORM's default many2many ergonomics. |
Polymorphic file attachments (attachOne / attachMany to System\Models\File) |
Collection has attachMany 'photos' (ordered by sort_order) and attachOne 'image' (public => true); Album has attachMany cover photos |
HIGH | WinterCMS's System\Models\File is a single polymorphic files table (attachable to any model). This is Płytarium's entire file/image storage model — a Go port needs a generic polymorphic attachment table (owner type + owner id + field name + disk path), not a bespoke album_photos table per model. Public vs. private disk visibility is part of the contract ('public' => true on the image relation). |
$jsonable array-cast columns (Storm-specific, distinct from Laravel's array cast) |
Album->$jsonable = ['tracklist', 'cover_import_failures']; OAuthClient->$jsonable = ['redirect_uris', 'grant_types', 'scope_ceiling'] |
MEDIUM | Needs a documented Go equivalent (custom GORM type / JSON column serializer) so JSON round-trips exactly as the PHP $jsonable trait did — no $casts => array semantics differences (e.g., empty-array vs null handling). |
$fillable allow-list + $guarded = ['*'] mass-assignment discipline |
Every model in the plugin sets $fillable explicitly and resets $guarded — this is the actual authorization boundary for what a PUT/POST body can touch (e.g., Collection's public_token, kind, and the three OAuth secret fields are deliberately excluded from $fillable) |
HIGH | This is a security-load-bearing pattern, not cosmetic — several Płytarium security invariants (D-01, D-09, Phase 9 D-01) depend on specific fields being absent from the allow-list. The Go ORM layer needs an equivalent explicit allow-list per model that request-binding code must go through, not raw struct-field binding. |
Winter\Storm\Database\Traits\Validation ($rules array validated on save) |
Every model (Artist, Genre, Style, Collection, Album) declares public $rules = [...] with Laravel-style rule strings (required, between:3,64, unique:table, `nullable |
integer | between:1889,2100`) |
Winter\Storm\Database\Traits\SoftDelete |
Album, Collection use it; cascading soft-delete of children in beforeDelete() (Collection::beforeDelete() soft-deletes every child Album inside a transaction before soft-deleting itself) |
MEDIUM | Standard GORM soft-delete covers the column; the cascading soft-delete inside a DB transaction, driven from a model lifecycle hook, is the part that needs an equivalent hook point. |
Model lifecycle hooks (beforeValidate, beforeCreate, beforeDelete, afterDelete) used for manual slug generation, dedup-key normalization, and cascade cleanup |
Artist::beforeValidate() (slug + name_key dedup normalization), Genre/Style::beforeValidate() (slug generation, Style has a bespoke padding algorithm for short names), Collection::beforeDelete() (cascade) |
HIGH | Płytarium does NOT use WinterCMS's Sluggable behavior anywhere — slugs are hand-rolled in beforeValidate(). Confirms PROJECT.md's fields.yaml list (no Sluggable dependency) but the lifecycle hook mechanism itself (pre-validate/pre-save/pre-delete/post-delete callbacks a model can override) is still table stakes — just as a plain hook, not a packaged behavior. |
Encrypted-at-rest column casts ('encrypted' cast, app-key-managed, IV+MAC) |
OrgDiscogsCredential/UserDiscogsCredential->casts = ['token' => 'encrypted']; OrgAiCredential/UserAiCredential->casts = ['api_key' => 'encrypted']; user's own Settings model uses the Encryptable trait |
HIGH | Explicitly documented in these models as "hand-rolled AES is forbidden" — this is a named security invariant, not incidental. Go port needs a encrypted GORM field type (AES-GCM or similar, app-secret-derived key) plus $hidden-equivalent (never serialize) on the same fields — both together are the actual contract. |
$hidden (never-serialize) fields, distinct from $fillable |
Collection->$hidden = ['public_token']; every *Credential model hides its secret column |
HIGH | Serialization-layer allow/deny-list, independent from the mass-assignment allow-list — two different boundaries on the same struct. A straight Go json:"-" tag can cover static cases, but public_token's hiding is conditional (one controller explicitly reads it out-of-band) — needs a serializer that supports "hidden by default, explicit override in one code path." |
| Custom attribute casts beyond primitives | Album->$casts = ['market_price_stored' => MarketPriceCast::class] — a hand-written class cast, explicitly chosen over Laravel's built-in decimal:4 because the built-in cast breaks Eloquent's dirty-checking on a blank-string "clear the price" input |
MEDIUM | Confirms the ORM layer needs a pluggable custom-cast mechanism (not just a fixed set of built-in types), and that a naive decimal cast can misbehave on an empty-string-clears-the-value UX — a documented pitfall to carry into the Go port. |
| Dedicated pivot model (not just a pivot array) | CollectionEditor is a first-class model referenced via 'pivotModel' => CollectionEditor::class on Collection's editors belongsToMany, carrying role, granted_at, granted_by |
MEDIUM | GORM's many2many supports a join-table struct; the design must let a pivot table carry business columns and be queried/validated like any other model, not just as opaque pivot data. |
Table-per-plugin naming convention (golem15_fonoteka_*) |
Every model's $table |
LOW | Cosmetic but must be preserved for a byte-compatible Postgres cutover if the same physical DB is reused, or explicitly renamed with a documented mapping otherwise. |
| Pagination, timestamps | Implicit across all list/index endpoints | LOW | Already named in PROJECT.md. |
HTTP / auth
| Feature | Why Expected | Complexity | Notes |
|---|---|---|---|
| Three parallel, mutually-exclusive auth groups on overlapping route sets | (1) /_fonoteka/api/v1 guarded by jwt.auth (SPA, cookie/JWT), (2) /api/v1/fonoteka guarded by inv.scope:<read|write|ai> (personal API tokens / MCP), (3) fully public groups (onboarding/*, public/{token}/*, public-wishlist/{token}/*, invitation inspection) — same controllers reused across groups 1 and 2 via TokenScope middleware binding the request user |
HIGH | This is the single hardest routing requirement: the same controller class must serve two differently-authenticated route groups with different exposed subsets of its actions (e.g., token group has zero tokens/oauth/CSV-import routes; JWT group has all of them). The routing layer needs first-class support for "same handler, different middleware stack, different route subset," not a 1:1 handler-per-route assumption. |
| Per-route, per-group rate limiting with distinct named buckets | 7+ named limiters in routes.php alone: fonoteka-api-token (60/min by token id), fonoteka-oauth-token (30/min by IP), fonoteka-oauth-register (30/min by IP), fonoteka-public-token (60/min by token), fonoteka-public-ip (120/min by IP), plus inline throttle:10,1 / throttle:20,1 / throttle:12,1 on individual sensitive routes (regenerate share link, upload photo, cover-price fetch, CSV import) |
HIGH | Needs a rate-limiter abstraction keyed by an arbitrary resolver function (token id, IP, route param), not just a global per-IP limiter — and the ability to stack two limiters on one route (['throttle:fonoteka-public-token', 'throttle:fonoteka-public-ip']). |
| Route-model binding / typed route params with regex constraints | ->where('id', '[0-9]+') on nearly every numeric-id route; string tokens with no constraint deliberately (so malformed tokens get the same 404 as unknown ones) |
LOW | net/http ServeMux (1.22+) wildcard patterns handle the shape; regex constraint needs to be layered on top (either in the mux pattern or a validating decorator). |
| Custom scoped-token auth guard, separate from JWT | ApiTokenGuard/ApiTokenManager/inv-token guard driver + inv.scope middleware — personal API tokens with a read|write|ai scope ceiling, distinct token model (ApiToken) |
HIGH | A second, independent credential type alongside JWT and OAuth2 bearer tokens — three different "who is this request" resolution strategies that must compose with the same downstream auth()->user()-equivalent. |
| OAuth2.1 authorization server (auth code + PKCE, refresh tokens, DCR) for MCP/ChatGPT | /.well-known/oauth-authorization-server (RFC 8414 metadata), /oauth/mcp/authorize (302 to a consent screen), /oauth/mcp/token (authorization_code + refresh_token grants), /oauth/mcp/register (RFC 7591 Dynamic Client Registration, JSON) — OAuthClient/OAuthAuthCode/OAuthRefreshToken models; consent flow (OAuthConsentController), connected-apps management (ConnectedAppController) |
HIGH | Already named in PROJECT.md (zitadel/oidc). Confirmed from source: needs RFC 8414 discovery, RFC 7591 DCR, PKCE, a resource-parameter check (RFC 8707) tolerant of "absent" vs strict on "present-and-different" (Claude vs. other clients differ here), and a separate rate-limit/no-CSRF/form-urlencoded requirement on the token endpoint (explicitly "NO web middleware group" since CSRF would break machine-to-machine POST). |
| Console-issued OAuth clients | fonoteka:oauth-client command: create, list (--list, never prints the secret again), add redirect URIs to an existing client without rotating the secret, set a scope ceiling |
MEDIUM | Confirms scaffolding-style admin console commands need argument/flag parsing with repeatable options (--redirect-uri=*, --scope=*) — already covered by the cobra pick. |
| Per-request locale + org context middleware | me/locale GET/PUT; organisation_id/organisation_role surfaced onto every user payload via an event seam; RequirePasswordChange (423 Locked) middleware gating an entire authed surface until a flag clears |
MEDIUM | Confirms PROJECT.md's "middleware for auth, org context, locale" line item; the locale field is user-scoped and persisted, not just an Accept-Language header. |
| SSRF-safe outbound fetch as a routed capability | albums/{id}/cover-price/discogs, albums/{id}/photos (manual cover_url) — both explicitly host-allow-listed and byte/time capped, called out as "an SSRF-fetch surface" in code comments |
MEDIUM | Not a generic feature, but any Go port of an endpoint that fetches an admin/user-supplied URL server-side must carry the same host/size/timeout guardrails — a pitfall as much as a feature. |
| CORS | Named in PROJECT.md; implicit for the Nuxt SPA talking cross-origin in dev | LOW | Standard. |
| 404-not-403 information-hiding convention on ownership-scoped resources, with an explicit narrow exception on the OAuth surface (401/403 there) | Stated directly in routes.php's OAuth section comment |
MEDIUM | A response-shape convention that must be preserved exactly for parity — not negotiable per PROJECT.md's "don't improve response shapes" constraint. |
Admin / forms
| Feature | Why Expected | Complexity | Notes |
|---|---|---|---|
fields.yaml → form schema, with span (left/right/full), context-scoped fields, and field-level attributes (e.g. readonly) |
All 5 controllers' models/*/fields.yaml; Style's slug field is context: update + attributes: { readonly: true } (editable only implicitly via slug-gen on create, read-only on edit) |
MEDIUM | Confirms PROJECT.md's field-type list is right for v1, but the schema needs layout hints (span) and per-context field visibility/attributes, not just field type + label. |
Field types actually used: text, textarea, checkbox, switch, dropdown (with a model-method options: callback), relation (with nameFrom, emptyOption), partial (arbitrary .htm include, tab-scoped, context-scoped) |
Every fields.yaml in the plugin |
HIGH | Correction to PROJECT.md's assumed list: Płytarium's 5 admin controllers do not use number, fileupload, repeater, or datepicker in their fields.yaml. They do use checkbox (distinct from switch), a dropdown fed by a model-side PHP method (getFormatOptions) rather than a static option list, and a partial field type that embeds an arbitrary template fragment (_editors.htm) inline in the form — this last one has no clean schema-driven equivalent and is effectively an escape hatch to hand-written markup. Flag partial as the one field type that may need a bespoke solution or a documented v1 gap (Collections' "editors" tab uses it only as a wrapper around the relation manager, so it may be replaceable by wiring the relation manager into the SPA directly instead of porting partial as a generic type). |
columns.yaml → list schema, with searchable, sortable, relation columns (relation: genre, select: name), and type: datetime/type: switch column renderers |
All 5 controllers' models/*/columns.yaml |
MEDIUM | Needs the list schema to express "render this related model's column" and a small set of column renderer types, separate from the form field types. |
| RelationController behavior (link/unlink management UI for a belongsToMany with a pivot) | Collections' config_relation.yaml — an editors relation manager with manage/view list configs, `toolbarButtons: link |
unlink, showSearch: true` |
HIGH |
Query-scoping hooks in the controller layer (listExtendQuery, formExtendQuery, formBeforeCreate, formBeforeUpdate, relationExtendManageQuery) |
Albums controller scopes every list/form query to the backend admin's "active collection" and rejects a cross-collection update attempt with a 404-shaped error; Collections controller auto-assigns a default owner on create and excludes the owner from the editor-picker |
HIGH | The admin controller is not just "render this YAML" — it's a real place for authorization/tenancy-scoping logic that runs before/after the generic CRUD behavior. The admin framework needs extension points at each CRUD lifecycle stage, not just declarative config. |
Bulk delete action wired to a list toolbar (index_onDelete) |
All 5 controllers implement index_onDelete() reading post('checked'), some with tenancy-scoping, all iterating individual ->delete() calls (so per-model cascade/soft-delete hooks still fire) |
MEDIUM | Confirms bulk actions must go through the same per-record model lifecycle as a single delete (not a raw bulk SQL delete), because at least one model's afterDelete has side effects (Genre/Style detach from Albums; Album's soft-delete cascade). |
Backend-only permissions gating both navigation and controller access ($requiredPermissions) |
Every controller declares $requiredPermissions = ['golem15.fonoteka.access_*'] |
LOW | Separate RBAC system from the frontend JWT/org roles — confirms PROJECT.md's "backend admin users, separate from frontend users." |
| OpenAPI-generated TypeScript types for the SPA | PROJECT.md requirement; not present in the PHP version (PHP relies on hand-written composables) but required for the new admin SPA | MEDIUM | New capability, not a port — the admin SPA is new, not a like-for-like port of a WinterCMS backend view. |
Jobs / realtime / search / files
| Feature | Why Expected | Complexity | Notes |
|---|---|---|---|
| Postgres-backed queued jobs with typed payloads, delayed re-dispatch, and per-job timeout | AlbumCsvImportJob (write pass, no network calls), AlbumCsvMatchJob (Discogs match pass, timeout = 240, explicitly re-dispatches itself with a delay on a rate-limit exception rather than failing the batch), WishlistDigestJob (30-minute coalescing window, deletes its own queue row on completion so the next item starts a fresh window) |
HIGH | River (already the named pick) supports delayed re-enqueue and per-job config; the debounce/coalescing pattern (WishlistDigestQueue accumulates a count, one job fires after a fixed delay, then the row is deleted) is a job-orchestration pattern to preserve exactly, not just "queue exists." |
A small job-management facade the domain plugin depends on (Golem15\Apparatus\Classes\JobManager, ApparatusQueueJob contract) |
All 3 jobs implement this contract (assignJobId, handle(JobManager $jobManager)) and call $jobManager->failJob()/completeJob() |
MEDIUM | Confirms jobs need a status-tracking layer above the raw queue (job succeeded/failed/skipped, recorded somewhere queryable) — not just "enqueue and forget." River's job table can serve this if the port wires status updates through it. |
| Scheduled/cron console commands | fonoteka:prune-notifications (daily, via registerSchedule), fonoteka:reindex (manual, with a --drop-old-items-index flag) |
LOW | Covered by the console + scheduling requirement above. |
| Full-text/faceted search via Typesense, toggleable per-deployment, tenant-scoped | Album uses Laravel\Scout\Searchable; Settings::get('search_use_typesense', false) gates syncing; ReindexAlbums verifies zero documents have collection_id:=0 (a multi-tenant leak check) before/after reindex, and can drop a legacy index name |
HIGH | Confirms the search index itself is tenant-scoped (collection_id as a filterable field) and the reindex command needs a tenant-leak assertion baked in, not just a bulk resync. Already the named Go pick (Typesense Go client) — the schema and the leak-check discipline are the parts to port faithfully. |
| Search syncing kill-switch that degrades gracefully with no DB / no config | guardSearchSyncing() disables search syncing if !App::hasDatabase() or the setting is off, wrapped in a try/catch |
LOW | A boot-time capability check pattern (don't crash if a downstream dependency is unconfigured) worth carrying over generally. |
| Realtime pub/sub over Centrifugo, with per-channel-namespace authorizers registered by the domain plugin | AuthorizerRegistry::register('collection', CollectionChannelAuthorizer::class) and ('wishlist', WishlistChannelAuthorizer::class); the SPA's useCentrifugo.ts fetches a connection token from GET /api/realtime/token (owned by the WebSockets stack plugin, not Fonoteka) and subscribes to per-user and per-collection/wishlist channels; server-side authorization is re-validated on every subscribe regardless of the client-requested channel name |
HIGH | Confirms PROJECT.md's "keep Centrifugo, port only the publisher and token issuing" framing exactly: the authorizer registry is a pluggable extension point other plugins hook into (Fonoteka registers 2 channel-namespace authorizers into a registry owned by the WebSockets plugin), and channel names deliberately never leak raw internal IDs the client could resubmit (me/context returns an opaque channel-name string, not a numeric collection_id). |
| Model-level "broadcastable" trait/interface for WS live-patch | Album implements BroadcastableInterface, use BroadcastableModel; explicitly a separate concern from the durable notification registry (WS broadcast is throttled during bulk writes via withoutBroadcasting; the notification listener in Plugin.php is not) |
HIGH | Two independent event-driven side-channels off the same model write (live WS patch vs. durable notification/email), each with its own throttling rule — must not be collapsed into one code path in the port. |
| Polymorphic file storage with public/private visibility per attachment | Covered above under Data/models; also the CSV import/export and cover-photo upload endpoints all go through this file layer | HIGH | gocloud.dev/blob is the named pick; the polymorphic-attachment table design is the part still to be specified. |
| CSV import as a multi-step, resumable, queued pipeline with a review/edit UI before commit | Routes: import/csv (store) → import/csv/{id} (show/poll) → PATCH mapping → PATCH rows/{rowId} (per-row edit) → commit → cancel; two distinct jobs (write pass vs. Discogs match pass) |
HIGH | Not a single "upload CSV, done" action — it's a stateful import session (CsvImport/CsvImportRow models) the user can edit row-by-row before committing, matching against Discogs asynchronously. This entire workflow, not just "a CSV parser," is table stakes. |
| CSV export | export/csv route on both the JWT and token auth groups |
LOW | Simple by comparison — a streamed/generated CSV download. |
Integrations
| Feature | Why Expected | Complexity | Notes |
|---|---|---|---|
| Discogs HTTP client with rate limiting, retry-after handling, and a wait budget | config/fonoteka.php's discogs.* block: rate_threshold: 50 (proactive, below Discogs' documented 60/min), wait_budget_seconds: 15 (hard budget before giving up inside one HTTP request), retry_after_fallback_seconds: 10; the match job explicitly re-dispatches on DiscogsRateLimitException rather than treating it as a batch failure |
HIGH | A generic net/http client is not enough — needs a rate limiter with a bounded in-request wait budget distinct from the job-level retry/backoff strategy used when the budget is exceeded. |
| Discogs credential resolution with a 3-tier precedence: env-configured site-admin token → org-shared BYOK → per-user BYOK | ai_org_lock config flag; OrgDiscogsCredential/UserDiscogsCredential/env DISCOGS_TOKEN; DiscogsConfigResolver/DiscogsGate classes |
HIGH | Same shape repeated for AI credentials (OrgAiCredential/UserAiCredential, ai_org_lock). This "3-tier BYOK-or-shared-or-org-locked credential resolution" is a reusable pattern, not incidental to Discogs — likely worth generalizing in the Go port rather than hand-copying twice. |
| SSRF-guarded outbound fetch for user-supplied URLs (cover images) | covers.manual_url_max_bytes (10MB), manual_url_timeout_seconds, contrasted explicitly with Discogs-only cover fetches which are additionally host-suffix-allow-listed (cover_host_suffix: .discogs.com) |
MEDIUM | Two different SSRF postures in the same plugin: Discogs cover fetch is host-locked; manual cover-URL fetch is open-host but size/time capped and behind an explicit SSRF guard class (ManualCoverUrlFetcher per code comments) — both need to be preserved distinctly. |
| AI cover recognition via pluggable providers (Anthropic, OpenAI), BYOK per-user or per-org | RecognizeApiController, UserAiCredential/OrgAiCredential (provider = claude|openai, optional model/base_url overrides) |
HIGH | Already named in PROJECT.md; confirmed the credential model supports overriding model name and base URL per credential (e.g., for OpenAI-compatible proxies), not just an API key. |
| MCP server as an OAuth2 client, talking to the personal-token surface for CRUD and to the OAuth endpoints for auth | fonoteka-mcp's client.ts is a thin Bearer-token proxy over /api/v1/fonoteka/*; server.ts/install.ts drive the OAuth2.1 authorize/token/register flow to obtain that bearer token |
HIGH | Confirms the OAuth surface and the personal-token surface are both real, separately-tested contracts the MCP server depends on — not just documentation. |
| Feedback submissions, sitemap output (stack plugins, not Fonoteka itself) | Named in PROJECT.md context as stack plugins Płytarium uses | LOW | Small, standalone features — a feedback-form-to-storage endpoint and a sitemap XML generator. Lower priority than the auth/data/admin core. |
Console / i18n
| Feature | Why Expected | Complexity | Notes |
|---|---|---|---|
| Namespaced translation keys resolved per-plugin, with per-locale mail template suffixes | golem15.fonoteka::lang.* keys throughout every yaml/model; mail templates registered as ...invitation and ...invitation-en pairs (pl implied default, en explicit suffix) |
MEDIUM | Matches PROJECT.md's vendor.plugin::group.key i18n design exactly; confirms the mail-template-per-locale-by-suffix convention specifically (not a generic i18n string catalog covering mail bodies). |
preferred_locale as a persisted user attribute, settable via its own always-available API route even when the account is otherwise locked |
me/locale GET/PUT registered on a separate middleware group (jwt.auth, bindings — deliberately without inv.must-change-password) so a forced-password-change screen can still switch language |
MEDIUM | A locale-switch endpoint must be reachable even when most of the API is gated behind an account-state lock — an ordering/exception detail in the middleware pipeline design. |
| Console commands with rich flags: repeatable options, mutually exclusive modes, listing without ever re-printing a secret | fonoteka:oauth-client's --redirect-uri=*, --scope=*, --list, --client-id= |
LOW | Already covered by the cobra pick; confirms repeatable flags and "never reprint a secret" as a concrete command-design convention to keep. |
Differentiators (competitive advantage over WinterCMS for the next port)
| Feature | Value Proposition | Complexity | Notes |
|---|---|---|---|
Compile-time plugin registration (no class_exists/elevated boot-order fragility) |
WinterCMS's elevated/noInit/class_exists-guarded optional dependency dance (seen 3+ times in Plugin.php alone) exists because plugin boot order and test-harness short-circuiting are runtime-discovered. A Go build graph makes plugin dependencies and boot order a compile-time fact, checked by go build/go vet, not a runtime guard a developer must remember to add. |
MEDIUM | Directly addresses a documented pain point in the PHP source (the elevated docblock explicitly narrates the bug class it exists to avoid). |
A single explicit "extension point" type (typed event bus with return-value collection) instead of ad hoc string-keyed Event::listen |
Płytarium hand-rolls the "collect all listeners' return values into one flat array" pattern for golem15.user.getApiArray via a code comment explaining the merge semantics. A typed Go event/hook system can make "fire and collect" vs. "fire and forget" vs. "fire until handled" three distinct, statically-checked primitives instead of one string-keyed bus with implicit per-event contracts. |
MEDIUM | Directly generalizes a pattern Płytarium needed twice (getApiArray, notification listeners) into a documented, reusable kernel primitive. |
| One generalized "3-tier BYOK-or-org-or-site-admin credential resolver" instead of two hand-copied implementations | AI and Discogs credentials both implement the same per-user/per-org/env precedence with an org-lock flag — currently two separate resolver classes | LOW-MEDIUM | Named above as a table-stakes pattern to generalize; the differentiator is doing it once, generically, in the framework/plugin-support layer rather than per-integration. |
Schema-driven admin with a native relation-manager and no partial-type escape hatch |
PocketBase's collection-schema-driven admin (best-fit reference per go-ecosystem.md) and Directus's field/relation model both avoid an arbitrary-template-fragment field type; Płytarium's one use of partial (Collections' editors tab) is really "show the relation manager in a tab," which a first-class relation-manager schema type can express without an escape hatch. |
MEDIUM | Removes the one field type that resists clean JSON-schema generation for the admin SPA + OpenAPI pipeline. |
| Policy-based, row/field-level backend permissions (Directus-style) instead of role-string permission points only | Płytarium's admin permissions are coarse role gates (UserRole::CODE_DEVELOPER on every permission) — fine for a household app, but Directus's free-tier field/row-level policy engine is the 2026 bar for "schema-driven admin done well." |
HIGH | Worth flagging as a v1.x differentiator, not v1 — Płytarium itself doesn't need it (single-admin household app), so building it now would be scope creep against the "table stakes = what Płytarium needs" rule; note it here so the next port (which may need finer admin RBAC) isn't surprised. |
go vet/go test as a correctness net over the entire plugin-extension surface |
WinterCMS's model behaviors, event listeners, and guard registration are invisible to any static tool; a wrong event name or a missing class_exists guard is a runtime failure discovered by a human or a test run. Compiled Go interfaces make most of this a build-time or go vet failure. |
LOW | Already named in go-ecosystem.md's plugin-architecture section as the core argument for compile-time registration; restated here as a direct product differentiator for the next plugin author, which is the milestone's explicit audience. |
| Single static binary deploy vs. PHP + Composer + opcache + queue worker processes | Simplifies the ops story for a project the size of Płytarium (household app) or the next port target | LOW | Already a given of the Go choice; mentioned for completeness against WinterCMS's deployment footprint. |
Anti-Features (WinterCMS subsystems to deliberately NOT port in v1)
| Anti-Feature | Why It Exists in WinterCMS | Why Problematic to Port Now | Alternative |
|---|---|---|---|
Server-rendered theme engine (Twig-like .htm partials/layouts/pages, component-in-template system) |
WinterCMS's original identity is a CMS with themeable front-end pages | Płytarium is 100% headless — its one _editors.htm partial is an admin-form escape hatch, not a public theme. Building a template engine to port zero real usage is pure scope creep. |
Keep the admin SPA schema-driven (form/list/relation-manager JSON); defer theming entirely to the keios.eu port (per PROJECT.md's Out of Scope) |
| Runtime plugin autoloading / marketplace-style hot install | WinterCMS plugins can be dropped into plugins/ and picked up without a rebuild |
Already rejected in why-go-not-scala.md and PROJECT.md's constraints; the class_exists/elevated fragility documented above is partly a consequence of runtime flexibility WinterCMS has to support that a compiled system doesn't need |
Compiled, build-time plugin registration (Caddy/xcaddy model), already the chosen design |
Backend AJAX framework (data-request attributes, partial-refresh AJAX handlers like index_onDelete triggered via a generic AJAX dispatcher) |
WinterCMS's whole backend UI is built on server-rendered forms + an AJAX framework that partially re-renders .htm fragments |
The new admin SPA is a Vue 3 + TypeScript app talking to a JSON API (OpenAPI-typed) — porting the AJAX-handler dispatch mechanism itself (as opposed to the capability it provides, e.g. bulk delete) would rebuild a rejected UI paradigm | Bulk actions, quick actions etc. become ordinary REST endpoints the SPA calls directly |
| WinterCMS's full Twig/markdown/mail-template rendering pipeline as a generic templating subsystem | Needed for theme pages and rich mail bodies with conditionals/loops | v1's mail needs (invitation, wishlist digest/purchase) are a handful of fixed templates per locale — a generic Twig-equivalent templating engine is more than what's used | html/template (stdlib) is sufficient per PROJECT.md's constraints; add a richer engine only if a later port needs it |
Nested-set / tree models (NestedTree behavior), Revisionable/audit-trail behavior |
Common WinterCMS behaviors for hierarchical taxonomies and change history | Not used anywhere in Płytarium (confirmed: no NestedTree/Revisionable usage in the plugin) — Genre/Style are flat lookup tables |
Skip entirely for v1; revisit only if a later port's domain needs a real tree or audit trail |
Sortable model behavior |
Common WinterCMS behavior for drag-reorder lists | Not used anywhere in Płytarium's models — the only "ordering" in play is a sort_order pivot column on two belongsToMany relations and an 'order' => 'name' static sort, neither of which needs a full drag-reorder behavior |
A plain integer pivot column + ORDER BY, no behavior needed |
Sluggable model behavior (as a packaged behavior) |
Common WinterCMS/Laravel-ecosystem behavior for auto-slug-from-name | Not used as a behavior anywhere in Płytarium — every slug is hand-rolled in beforeValidate(), including one bespoke padding algorithm for short strings that a generic Sluggable couldn't express anyway |
Keep model lifecycle hooks (table stakes above) as the mechanism; don't build a packaged Sluggable behavior for v1 — nothing in scope needs it and Płytarium's own slug logic wouldn't use it if it existed |
| MySQL / multi-database abstraction | WinterCMS supports MySQL, Postgres, SQLite | Already rejected in PROJECT.md ("Postgres only in v1... one migration target keeps the port simple") | Postgres only |
| Payments, chat/forum/video subsystems, WASM extension API | Other WinterCMS stack plugins / seeds for later projects | Explicitly out of scope per PROJECT.md, not used by Płytarium at all | Deferred to named later seeds (keios.eu, wavepath.org, wasm-extension-api.md) |
Feature Dependencies
Plugin descriptor (register/boot, elevated-equivalent)
└──requires──> Compiled plugin registry (build-time import list)
Cross-plugin event bus (fire-and-collect + fire-and-forget)
└──requires──> ORM model lifecycle events (created/updated/deleting)
└──enables───> Auth guard registration extension point (inv-token, inv.scope)
└──enables───> getApiArray-style payload extension (org fields on User)
└──enables───> Notification listeners (Album created -> NotificationService)
fields.yaml/columns.yaml -> JSON schema pipeline
└──requires──> Custom field-type registry (text, textarea, checkbox, switch,
dropdown-with-callback, relation, datetime column, partial-or-replacement)
└──requires──> $fillable/$hidden serialization discipline (security boundary)
└──enables───> Admin SPA rendering (forms + lists)
└──enables───> RelationController-equivalent (link/unlink UI)
└──requires──> Pivot model with business columns (CollectionEditor)
Three parallel auth groups (JWT / personal-token / public)
└──requires──> Named middleware pipeline + per-route rate-limit buckets
└──requires──> Same-handler-different-route-subset routing capability
└──enables───> OAuth2.1 authorization server (auth code + PKCE + DCR)
└──enables───> MCP server / ChatGPT connector integration
Polymorphic file attachment table (attachOne/attachMany)
└──enables───> Cover photo upload, Collection image, CSV-adjacent file flows
Queued jobs (River) + JobManager status contract
└──enables───> CSV import pipeline (multi-step, resumable, reviewable)
└──enables───> Discogs match pass (rate-limited, self-redispatching)
└──enables───> Wishlist digest (debounce/coalesce pattern)
Realtime channel-authorizer registry (WebSockets plugin)
└──requires──> Cross-plugin event/registry extension point (same primitive as above)
└──enables───> Collection/Wishlist live-patch channels
└──conflicts──> Naive single-channel-per-model design (two independent channels:
WS live-patch vs. durable notification registry, different throttling)
3-tier credential resolver (env -> org BYOK -> user BYOK, org-lock flag)
└──requires──> Encrypted-at-rest column cast + $hidden serialization
└──enables───> Discogs import, AI cover recognition (both instances of the same pattern)
Dependency Notes
- Admin SPA rendering requires the fields.yaml/columns.yaml pipeline, which requires the field-type registry and the serialization security boundary — these three cannot be phased independently; a phase that ships "forms" without also nailing down
$fillable/$hiddensemantics will under-build the security contract Płytarium actually depends on (several models exclude specific fields from$fillablefor named security reasons, e.g.public_token,kind). - The OAuth2.1 server depends on the personal-token auth group existing first conceptually (both are "not the SPA's JWT" auth strategies sharing the routing pattern of same-handler/different-middleware), but they are two distinct credential types in Płytarium's actual code (
ApiTokenvs.OAuthClient/OAuthRefreshToken) — build the routing/middleware abstraction once, then implement both guards against it, rather than building one and retrofitting the other. - Realtime authorizer registry and the cross-plugin "fire and collect" event bus are the same underlying kernel primitive (a plugin registers a handler into another plugin's registry/hook point) — the
AuthorizerRegistry::register()pattern and theEvent::listen('golem15.user.getApiArray', ...)pattern should converge on one Go design, not two. - CSV import conflicts with a "keep it simple" queue design: it needs delayed self-redispatch on rate-limit (not just retry-with-backoff-then-fail) and a stateful multi-step session model (
CsvImport/CsvImportRow) — plan for this complexity explicitly rather than assuming River's defaults are enough out of the box. - The generalized 3-tier credential resolver enhances but does not block the Discogs and AI integrations — v1 could ship two copies (matching the PHP source exactly) and generalize later; flagged as a differentiator, not a blocking dependency.
MVP Definition
Launch With (v1 — Płytarium parity)
Everything in Table Stakes above, prioritized by what blocks the Nuxt app / MCP server from working at all:
- Plugin descriptor + compiled registry + cross-plugin event bus — nothing else can be built without this
- GORM models with all relation types,
$jsonable,$fillable/$hidden/encryptedcast discipline, lifecycle hooks, cascading soft-delete — the data layer every endpoint touches - Three-auth-group routing (JWT, personal-token, public) with per-bucket rate limiting — the entire API surface sits behind this
- OAuth2.1 server (zitadel/oidc) — MCP server and ChatGPT connector are dead without it
- All ~160 routes across the 5 domain resources (collections, albums, wishlist, sharing, notifications, tokens, credentials, CSV import/export, onboarding) — this is the acceptance test
- fields.yaml/columns.yaml pipeline + relation-manager schema type, for the 5 admin controllers
- River queue with CSV import (multi-step) and wishlist digest (debounce) jobs
- Centrifugo publisher + channel-authorizer registry + token issuing
- Typesense sync with tenant-scoped (
collection_id) filtering - Polymorphic file attachment layer via
gocloud.dev/blob - Discogs + AI (Anthropic/OpenAI) clients with BYOK credential resolution
- i18n (pl/en) with namespaced keys and per-locale mail templates
Add After Validation (v1.x)
- Generalize the 3-tier credential resolver into one reusable framework/plugin-support piece (currently two hand-copied instances in the PHP source)
- Replace the
partialfield-type escape hatch with a first-class relation-manager-in-tab schema, if a later port needs more than Płytarium's one instance - Policy-based, row/field-level backend permissions (Directus-style), if a later port's admin needs finer-grained RBAC than Płytarium's single-role-gate model
Future Consideration (v2+)
- Server-rendered theme engine (Twig-like), component-in-template system — only for the keios.eu port
- Payments — only for the keios.eu port
- Sortable/NestedTree/Revisionable model behaviors — build only when a real domain plugin needs them; nothing in Płytarium does
- WASM sandboxed extension API — deferred per existing seed, needs a stable compiled-plugin API for a full milestone first
Feature Prioritization Matrix
| Feature | User Value (parity impact) | Implementation Cost | Priority |
|---|---|---|---|
| Cross-plugin event bus (fire-and-collect + fire-and-forget) | HIGH | MEDIUM | P1 |
| GORM relation/pivot/cast/hook fidelity | HIGH | HIGH | P1 |
| Three-auth-group routing + rate limiting | HIGH | HIGH | P1 |
| OAuth2.1 server (zitadel/oidc) | HIGH | HIGH | P1 |
| fields.yaml/columns.yaml pipeline + relation manager | HIGH | HIGH | P1 |
| Polymorphic file attachments | HIGH | HIGH | P1 |
| River jobs incl. CSV import/digest patterns | HIGH | HIGH | P1 |
| Centrifugo publisher + authorizer registry | MEDIUM | MEDIUM | P1 |
| Typesense tenant-scoped sync | MEDIUM | MEDIUM | P1 |
| Discogs/AI clients + BYOK credentials | MEDIUM | MEDIUM | P1 |
| i18n + locale-suffixed mail templates | MEDIUM | LOW | P1 |
| Generalized 3-tier credential resolver | LOW (parity-neutral) | LOW | P2 |
Relation-manager as first-class schema (replace partial) |
LOW (parity-neutral) | MEDIUM | P2 |
| Policy-based row/field-level admin permissions | LOW (not needed by Płytarium) | HIGH | P3 |
| Theme engine, payments | NONE for v1 | HIGH | P3 (later milestone) |
| Sortable/NestedTree/Revisionable behaviors | NONE (unused) | MEDIUM | P3 (build on demand) |
Competitor / Reference Feature Analysis
| Feature | PocketBase | Directus | Strapi | Goravel | SummerCMS Approach |
|---|---|---|---|---|---|
| Schema source of truth | Collection schema owned by PocketBase itself (SQLite) | Introspects an existing SQL schema (database-first) | Schema as code (JSON files in the Strapi project) | No CMS-schema concept — it's a Laravel-shaped app framework, not a CMS | Model-first (GORM structs + migrations), same as Płytarium's PHP source — closest to Strapi's "schema as code," not Directus's introspection |
| Admin UI | Built-in, auto-generated from collection schema; hooks + goja (JS) plugin VM | Vue "Data Studio": collections/records, custom layouts (kanban/calendar/map), Flows automation | React admin, content-type builder, RBAC behind paid tiers for advanced rules | None — bring your own frontend | Minimal Vue 3 + TS SPA for 5 controllers' forms/lists/relation-manager only — deliberately not a general-purpose content-type builder |
| Permissions | Basic per-collection rules (SQL-like expressions) | Free-tier policy-based, field/row-level per role | Basic RBAC free; granular rules paid | Framework-level Auth facade, no admin-specific RBAC | Role-gated permission strings + backend/frontend RBAC split, matching Płytarium's actual (coarse) needs; row/field-level policies flagged as a v1.x differentiator, not v1 |
| Realtime | Built-in SSE/realtime subscriptions per collection | Not a core feature (via Flows/webhooks) | Not a core feature | None built-in | Centrifugo (existing infra) + a channel-authorizer registry extension point, not a built-in pub/sub — deliberately keeps the existing WS server rather than replacing it |
| Plugin/extensibility model | JS VM (goja) hooks, single Go binary | Extensions (interfaces, hooks, endpoints, modules) as separate npm packages | Plugins as npm packages + lifecycle hooks | Facades + service providers (Laravel-style DI) | Compiled Go modules registered at build time — closer to Goravel's DI-facade shape than PocketBase's embedded-JS-VM or Directus/Strapi's npm-package model, chosen specifically because Płytarium's plugins need typed, compiled cross-plugin extension (event bus, guard registration, registries), which an embedded scripting VM or loosely-typed npm plugin can't give the same static-checking guarantees for |
| Best-fit takeaway | Best reference for collection-schema-driven admin rendering (forms/lists from schema) | Best reference for field/row-level permission policy design (v1.x differentiator) | Best reference for schema-as-code (matches SummerCMS's model-first approach) | Best reference for Laravel-shaped facade/DI ergonomics in Go (reference only, not a base) | — |
Sources
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php— plugin lifecycle, event listeners, permissions, navigation, settings, mail templates, scheduling (HIGH confidence, primary source)/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php(569 lines, ~160 routes) — three auth groups, rate-limit buckets, OAuth2.1 endpoints (HIGH confidence, primary source)/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/{Albums,Artists,Collections,Genres,Styles}.phpand theirconfig_form.yaml/config_list.yaml/config_relation.yaml(HIGH confidence, primary source)/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/*.php(25 models) andmodels/{album,artist,collection,genre,style,settings}/{fields,columns}.yaml(HIGH confidence, primary source)/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/jobs/{AlbumCsvImportJob,AlbumCsvMatchJob,WishlistDigestJob}.php,console/{IssueOAuthClient,PruneNotifications,ReindexAlbums}.php,config/fonoteka.php(HIGH confidence, primary source)/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/(models/User.php, guards, JWT service provider) and/media/nvme/dev/golem15/fonoteka/plugins/golem15/websockets/(classes: AuthorizerRegistry, CentrifugoBroadcaster, CentrifugoClient, JwtTokenGenerator) — skimmed (MEDIUM-HIGH confidence)/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/useCentrifugo.tsand/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/client.ts— skimmed for client-side contract confirmation (MEDIUM confidence).planning/PROJECT.md,.planning/notes/v1-target-plytarium.md,.planning/research/go-ecosystem.md(this repo) — prior decisions and ecosystem picks (HIGH confidence, internal)- WebSearch: Directus vs Strapi vs PocketBase 2026 comparisons (MEDIUM confidence, cross-checked against training knowledge) — https://directus.com/strapi , https://unfoldcms.com/blog/strapi-vs-directus-2026 , https://elmapicms.com/blog/payload-strapi-directus-which-one-2026
- WebSearch: Goravel framework facades/ORM/queue 2026 (MEDIUM confidence) — https://docs.goravel.dev/ , https://pkg.go.dev/github.com/goravel/framework/facades , https://www.goravel.dev/architecutre-concepts/facades.html
Feature research for: WinterCMS/Laravel-shaped CMS framework in Go, v1 = Płytarium headless backend port Researched: 2026-09-16