Module Layout Convention (3-Tier Rule)
Where a new interface, enum, extension point, or entry point physically lives inside src/ of any OSS lib in the MIDDAG PHP ecosystem (middag-php-framework, middag-php-ui, and their adapters). See Architecture for the wider ecosystem this convention sits inside.
Concern-first at the root
The layout is concern-first at the root of src/: each responsibility gets its own top-level directory (Bus/, Database/, Http/, Form/, Persistence/, …). There is no generic Infrastructure/, Service/ or Helper/ at the root — the directory name is the what, never the layer.
The three tiers, inside each concern
src/<Concern>/
├── Contract/ # TIER-TYPE — interfaces only, *Interface suffix, zero impl
│ └── ReportRendererInterface.php
├── Enum/ # TIER-TYPE — backed, immutable enums
├── Attribute/ # TIER-TYPE — PHP 8 metadata (#[Attribute])
├── Renderer/ # TIER-SEAM — extension point named by role,
│ ├── HtmlReportRenderer.php # created from the first file
│ └── RendererRegistry.php
├── Report.php # TIER-ROOT — concern entry point / active-record base
└── ReportFactory.php # TIER-ROOT — factory / orchestrator of the concern| Tier | What it is | Sub-namespace |
|---|---|---|
| TIER-TYPE | Contract/, Enum/, Attribute/ — never empty when they exist | inside the concern |
| TIER-SEAM | A subfolder by role/extension point (Renderer/, Field/, Transport/) | inside the concern |
| TIER-ROOT | Entry points + active-record bases | concern root |
The rule that decides whether something gets a subfolder is boundary-based, not a file-count threshold: a subfolder marks where consumers extend the concern, not "a place with two or more files." A seam with exactly one file in it today is still a seam, and stays in its own subfolder.
Rules to apply when adding a new file
- Create the seam from the first file — never "promote later." PSR-4 ties namespace to path; moving
Foo.php→Renderer/Foo.phpafter the fact ripples the namespace change across every repo that imports it. - TIER-TYPE is absolute for the whole ecosystem. Every interface, in every lib, lives in
<Concern>/Contract/— no exceptions (for examplePage/Contract/PageInterface.php,Bus/Contract/MessageBusInterface.php). No interface lives at the concern root, and the*Interfacesuffix is mandatory. - Each concern owns its own
Contract/. There is no global, cross-concernContract/directory anywhere in the ecosystem.
Quick lookup
| You're adding… | It goes in… |
|---|---|
A new interface for concern Foo | Foo/Contract/FooSomethingInterface.php |
A backed enum for concern Foo | Foo/Enum/ |
A PHP 8 attribute for concern Foo | Foo/Attribute/ |
| A new extension point (a role a consumer can plug into) | Its own named subfolder under the concern, from the first file (Foo/Renderer/, Foo/Field/, Foo/Transport/, …) |
| The concern's main entry point, factory, or orchestrator | Concern root (Foo/Foo.php, Foo/FooFactory.php) |
Moodle adapter: host domain surface
The Moodle adapter (middag-io/moodle) applies the same layout and adds one rule for Moodle's own types. Verified against middag-php-moodle main@6aae04d on 2026-11-04.
Typed representations of Moodle objects live in src/Domain/<Subsystem>/, one folder per Moodle capability area: Backup, Calendar, Completion, Context, Course, CustomField, Enrolment, Event, File, Grade, Group, Message, Platform, Role, Task, User.
src/Domain/Group/
├── Contract/ # local interfaces (GroupServiceInterface), not framework ports
├── Enum/ # Moodle constants as backed enums (GroupMode)
├── Group.php # entity (stdClass → typed)
├── GroupMemberDto.php # DTO
└── GroupService.php # host domain service, implements Contract/GroupServiceInterface| Rule |
|---|
The Contract/ interfaces under Domain/ describe the Moodle host domain. They stay local to the adapter and are not promoted to framework ports |
Entities, DTOs, enums and contracts are @api by default; infrastructure-only classes opt out with @internal (see API Coverage & Version Gates) |
Domain/AbstractMoodleEntity implements the framework EntityInterface |
The folders follow Moodle's capability areas because the types mirror Moodle objects. Everything else in the adapter stays concern-first (Database/, Http/, Settings/, Support/, …) |
| A new folder is admitted only for a genuinely distinct native Moodle mechanism, never because a folder has grown to some file count |
The legacy moodle-local_middag plugin organized its boundary by technical type (adapter/, contract/, service/, definition/, dto/, entity/, enum/, settings/, form/, support/). In the adapter those roles map as follows:
| Legacy folder | Adapter location |
|---|---|
adapter/ | The concern that owns the port (Security/, Logging/, Database/, Translation/, Output/, …) |
contract/ | The owning concern's Contract/ (Security/Contract/, Privacy/Contract/, Domain/<Subsystem>/Contract/, …) |
service/ | Domain/<Subsystem>/*Service (UserService, MessageService, AdhocService, ScheduledService, …) |
definition/ | Definition/ (*Definition, see Static Generation) |
dto/, entity/ | Domain/<Subsystem>/ |
enum/ | Domain/<Subsystem>/Enum/, Security/Enum/, Settings/Enum/, Shared/Enum/ |
settings/ | Settings/ and Settings/Type/ (see Settings & Config Keys) |
form/ | Form/ (MformRenderer) |
support/ | Support/ (see Support Layer) |
Decision record: MDL-002 in middag-php-moodle/docs/decisions.