Files
summercms/.planning/research/FEATURES.md
2026-09-16 03:00:12 +02:00

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/$hidden semantics will under-build the security contract Płytarium actually depends on (several models exclude specific fields from $fillable for 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 (ApiToken vs. 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 the Event::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/encrypted cast 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 partial field-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}.php and their config_form.yaml/config_list.yaml/config_relation.yaml (HIGH confidence, primary source)
  • /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/*.php (25 models) and models/{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.ts and /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