Files
summercms/.planning/notes/plugin-layout-winter-directories.md
Jakub Zych a9c77fcf49 docs(05-01): confirm models-leaf rule against three more plugins
- Probe keios.eu user, jz chat, and pxpx checkout for models→sibling uses
- Every live edge is a cast or service-calling hook; no "other" category
- Close folded todo verify-models-leaf-rule; bulk port may proceed
2026-09-18 18:37:26 +02:00

9.3 KiB

title, date, context
title date context
Plugin file layout — WinterCMS directories as Go subpackages, models is a leaf 2026-09-18 /gsd-explore during Phase 4 discussion (scaffold stub layout question); binds every plugin port (golem15, jz, pxpx)

Plugin file layout

Decision

A Go plugin mirrors the WinterCMS plugin directory layout. Each PHP directory becomes a Go package (or an embedded asset directory) under the plugin module root:

WinterCMS Go plugin Kind
Plugin.php plugin.go (root package) registration; imports the leaves
routes.php routes.go (root package) surf group builder
models/ models/ package, leaf (see rule)
classes/ classes/ package, services, hooks, casts that need services
controllers/ controllers/ package, HTTP handlers and admin controllers
console/ console/ package, bonfire.Command implementations
jobs/ jobs/ package, framework Job interface (River from Phase 11)
middleware/ middleware/ package, named middleware
updates/ updates/ package, the plugin's gormigrate set
lang/<locale>/*.yaml lang/<locale>/*.yaml embedded assets (HasLang)
views/mail/ views/mail/ embedded assets (HasMailTemplates)
config/ config/ embedded assets (HasConfig)

Chosen because conversion is mostly done by agents across many plugins: models/Album.php → models/album.go is a mechanical move and the agent's mental map of the plugin stays 1:1 with the PHP source. The alternative (flat single package, which the Phase 3 fonoteka.go plugins use) removes all intra-plugin imports but breaks the directory correspondence that conversion leans on.

The rule: models/ is a leaf package

models/ never imports a sibling package of its own plugin. Consequences for the converter:

  • Casts move into models/. PHP files classes/casts/* (e.g. Fonoteka MarketPriceCast) are model concerns filed elsewhere; in Go they live in models/ (or in the framework lagoon when generic). The same treatment covers other model-owned types filed outside models/ in PHP: contracts the model implements, jsonable value objects and their factories, money/MIME formatters, and type-mapping policies.
  • Service-calling lifecycle hooks move out. A PHP model hook that reaches into classes/ (e.g. Album::beforeSave using ArtistResolver) becomes a GORM callback registered on the model from classes/ or plugin.go — the same callback-registry mechanism Phase 5 uses for cross-plugin model extension. One pattern for both intra- and cross-plugin hooks. The same treatment covers other service-calling methods on the model: afterSave cache busts, job dispatch, broadcasting traits, and findOrCreate/resolver methods that reach a sibling service.
  • Hooks that only touch the model itself (slug generation, timestamps) stay as methods on the model.

The rule is enforced by tooling, not documentation: summer build (or a framework go test) fails when a plugin's models/ package imports another package of the same plugin module.

Evidence (Fonoteka PHP plugin, 2026-09-18)

Cross-directory use edges in plugins/golem15/fonoteka:

  • classes/ → models/: 40 files
  • models/ → classes/: 1 (models/Album.php: Classes\ArtistResolver, Classes\Casts\MarketPriceCast)
  • models/ → controllers/: 0; classes/ → controllers/: 0
  • jobs/, console/, middleware/ → classes/: 5; traits/ → models/: 2

So the only cycle in the directory graph is models ↔ classes, caused by one file, in exactly the two categories the rule addresses (a cast and a service-calling hook). Everything else is already a DAG: models ← classes ← jobs/console/middleware/controllers ← plugin.go.

Evidence (keios.eu golem15/user, 2026-09-18)

Probe: grep -rn "Golem15\\\\User\\\\Classes" plugins/golem15/user/models plus the reverse classes/ → models/ direction, then every Golem15\User\ sibling use from models/.

Cross-directory use edges in plugins/golem15/user:

  • classes/ → models/: 2 files (AuthManager.php, FamilyAuth.php)
  • models/ → classes/: 0
  • models/ → controllers/: 0; controllers/ → models/: 9
  • commands/ → models/: 1; repositories/ → models/: 1; middleware/ → models/: 0
  • models/ → any sibling: 1 (models/User.php: Contracts\UserRepository)
File Sibling import Classification
models/User.php Contracts\UserRepository service-calling hook — afterSave() does Cache::forget(UserRepository::CACHE_KEY_PREFIX . $this->id). Conversion: register the cache-bust from classes/ (or keep the constant next to the model).

No models/ → classes/ cycle. The one sibling edge is a lifecycle hook, the same category Fonoteka's ArtistResolver already covers. DAG: models ← classes/repositories/commands/controllers ← plugin.go.

Evidence (jz golem15/chat, 2026-09-18)

Probe: grep -rn "Golem15\\\\Chat\\\\Classes" plugins/golem15/chat/models plus reverse classes/ → models/ and every Golem15\Chat\ sibling use from models/.

Cross-directory use edges in plugins/golem15/chat:

  • classes/ → models/: 2 files (ChatChannelAuthorizer.php, ChatService.php)
  • models/ → classes/: 1 (models/Message.php: Classes\ChatAttachmentPolicy)
  • models/ → controllers/: 0; controllers/ → models/: 1
  • jobs/ → models/: 1; console/ → models/: 1; traits/ → models/: 0
  • models/ → any sibling: 4 (table below)
File Sibling import Classification
models/Message.php Classes\ChatAttachmentPolicy cast — ChatAttachmentPolicy::kindFor($mime) is a MIME→kind type mapping used when serializing attachments, the same "model concern filed in classes/" as Fonoteka MarketPriceCast. Conversion: policy lives in models/ (or the serialize method moves to classes/, matching SerializesFonoteka).
models/Message.php Traits\ChatBroadcasting service-calling hook — afterCreate fan-out via Centrifugo. Conversion: register from classes/ (Phase 11 broadcast seam).
models/Message.php Jobs\MaintainThreadSummaryJob service-calling hook — afterCreate/afterDelete dispatch. Conversion: register from classes/.
models/Conversation.php Contracts\ChatContextInterface service-calling hook — getChatContext() resolves app('chat.context.resolver'); findOrCreateDmInContext/findOrCreateGroupChat are factory methods that take the adapter. Conversion: resolver/factory methods live in classes/; the interface can sit with them or in models/.

The models ↔ classes cycle is one file, one cast. The other three sibling edges are service-calling hooks. Same two categories as Fonoteka.

Evidence (pxpx pixelpixel/checkout, 2026-09-18)

Probe: grep -rn "Pixelpixel\\\\Checkout\\\\Classes" (and the live namespace PixelPixel\CheckOut\) on plugins/pixelpixel/checkout/models, plus reverse classes/ → models/ and every in-plugin sibling use from models/. PHP namespaces are PixelPixel\CheckOut\ (mixed case) against directory pixelpixel/checkout.

Cross-directory use edges in plugins/pixelpixel/checkout:

  • classes/ → models/: 0
  • models/ → classes/: 0
  • models/ → controllers/: 0; controllers/ → models/: 14
  • jobs/ → models/: 2; console/ → models/: 2; support/ → models/: 1; factories/ → models/: 3
  • models/ → any sibling: 6 use lines (4 live, 2 unused imports)
File Sibling import Classification
models/Cart.php Contracts\CartInterface cast — the model implements a contract that describes itself. Conversion: interface lives in models/.
models/Cart.php ValueObjects\CartItem cast — jsonable line-item VO stored on content. Conversion: VO lives in models/.
models/Cart.php Factories\CartItemFactory cast — factory for that jsonable VO (getItemsList). Conversion: factory lives in models/ next to the VO.
models/Payment.php Facades\MoneyFormatter cast — display accessors format stored money, same job as Fonoteka MarketPriceCast. Conversion: formatter lives in models/ (or lagoon if generic).
models/Cart.php Support\MoneyHelp unused import — not a live edge
models/Order.php Support\CartManager unused import — not a live edge

No models/ → classes/ cycle. Every live sibling edge is a model-owned type (the widened "cast" treatment). DAG: models ← support/factories/jobs/console/controllers ← plugin.go.

Across the three plugins plus Fonoteka, every live models/ → sibling edge is cast or service-calling hook. The leaf rule holds; no "other" category was found.

Trade-off accepted

A converted model no longer shows its whole lifecycle in one file: models/album.go plus classes/album_hooks.go together replace Album.php. This is the price of the rule being mechanical and checkable before compile.

Open

  • The Phase 3 fonoteka.go plugins (golem15.user, golem15.fonoteka) are flat. They are restructured into this layout when Phase 5 widens them, not in Phase 4 (Phase 4 writes only to summercms.go).