# 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`) | HIGH | This is the YAML/array `rules:` requirement already named in PROJECT.md — `go-playground/validator` is the named pick. Needs `unique:table` (DB-hitting rule) and cross-field/conditional rules to match. | | `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:` (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 | This is a third schema type beyond form+list: a **relation manager** UI (search-and-attach/detach a related record) that only some controllers need. It's used exactly once across Płytarium's 5 controllers but is the single most complex admin behavior — building the fields.yaml→JSON pipeline without at least one relation-manager schema would be incomplete for the actual v1 scope. | | 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*