- 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
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 filesclasses/casts/*(e.g. FonotekaMarketPriceCast) are model concerns filed elsewhere; in Go they live inmodels/(or in the frameworklagoonwhen generic). The same treatment covers other model-owned types filed outsidemodels/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::beforeSaveusingArtistResolver) becomes a GORM callback registered on the model fromclasses/orplugin.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:afterSavecache busts, job dispatch, broadcasting traits, andfindOrCreate/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 filesmodels/→classes/: 1 (models/Album.php:Classes\ArtistResolver,Classes\Casts\MarketPriceCast)models/→controllers/: 0;classes/→controllers/: 0jobs/,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/: 0models/→controllers/: 0;controllers/→models/: 9commands/→models/: 1;repositories/→models/: 1;middleware/→models/: 0models/→ 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/: 1jobs/→models/: 1;console/→models/: 1;traits/→models/: 0models/→ 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/: 0models/→classes/: 0models/→controllers/: 0;controllers/→models/: 14jobs/→models/: 2;console/→models/: 2;support/→models/: 1;factories/→models/: 3models/→ any sibling: 6uselines (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.goplugins (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 tosummercms.go).