Skip to content

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
TierWhat it isSub-namespace
TIER-TYPEContract/, Enum/, Attribute/ — never empty when they existinside the concern
TIER-SEAMA subfolder by role/extension point (Renderer/, Field/, Transport/)inside the concern
TIER-ROOTEntry points + active-record basesconcern 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 ​

  1. Create the seam from the first file — never "promote later." PSR-4 ties namespace to path; moving Foo.php → Renderer/Foo.php after the fact ripples the namespace change across every repo that imports it.
  2. TIER-TYPE is absolute for the whole ecosystem. Every interface, in every lib, lives in <Concern>/Contract/ — no exceptions (for example Page/Contract/PageInterface.php, Bus/Contract/MessageBusInterface.php). No interface lives at the concern root, and the *Interface suffix is mandatory.
  3. Each concern owns its own Contract/. There is no global, cross-concern Contract/ directory anywhere in the ecosystem.

Quick lookup ​

You're adding…It goes in…
A new interface for concern FooFoo/Contract/FooSomethingInterface.php
A backed enum for concern FooFoo/Enum/
A PHP 8 attribute for concern FooFoo/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 orchestratorConcern 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 folderAdapter 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.

MIDDAG © 2015-2026