Skip to content

Bridge Contracts (Adapter Ports) ​

The interfaces a host adapter (Moodle, WordPress, or a future host) implements to plug into middag-io/framework. Backs FW-015 (hosts are adapters of the framework's ports) and FW-014 (controller roles). See Architecture for how these ports fit into the ecosystem.

Rules every port follows ​

  • A host enters only through an adapter. Only adapter packages call host APIs; the framework calls none (enforced by composer check:portability, see Composer Portability Audit).
  • Every port has an OSS default or is optional. The framework runs standalone with no adapter installed; an adapter only overrides a default. The standalone runtime is described in Standalone Runtime.
  • The consumer owns the composition root. The product (or a standalone app) chooses the bootstrap, the adapter and the bindings. The OSS libraries ship no composition root of their own.
  • No product identity in the libraries. Component name, asset version and base path reach the adapter at runtime through HostComponentContextInterface; defaults are neutral.
  • Adapters never import proprietary code. Governed concerns live in proprietary packages downstream, which build on the same ports; an adapter is only ever handed what it must deliver to the host.

Contract table ​

Adapter class names verified against middag-php-moodle/main@6aae04d and middag-php-wordpress/main@bc1bc59 on 2026-11-04. "—" means the adapter does not implement the port and the OSS default (or the consumer's own binding) applies.

ContractHome + OSS defaultMoodleWordPress
BootstrapInterfaceKernel/Contract/ — a standalone app writes its ownMoodleBootstrapWpBootstrap
ConfigResolverInterfaceKernel/Contract/ — EnvConfigResolverMoodleConfigResolverWpConfigResolver
HostComponentContextInterfaceKernel/Contract/ — neutral component identity (componentName/assetVersion/basePath); optional process-wide slot Kernel/HostContextMoodleHostContextWpComponentContext (injected, never through HostContext; see below)
MaintenanceGateInterfaceKernel/Contract/ — NullMaintenanceGateMoodleMaintenanceGate ($CFG->upgraderunning, during_initial_install())WpMaintenanceGate (wp_is_maintenance_mode())
HookManagerInterfaceKernel/Contract/ — HookManager, bound by the consumer— (MoodleHostEventBridge consumes the bound manager)— (host events go to native WordPress hooks)
HostEventBridgeInterfaceKernel/Contract/ — @api, no defaultMoodleHostEventBridge (delegates to HookManager)WpHostEventBridge (native do_action, middag/ prefix)
ConnectionAdapterInterfaceDatabase/Contract/ — PdoConnectionAdapterMoodleConnectionAdapterWpdbConnectionAdapter
SqlDialectInterfaceDatabase/Contract/ — StandardSqlDialect, MysqlSqlDialect, PrefixingSqlDialectMoodleSqlDialectWpdbSqlDialect
SchemaBuilderAdapterInterfaceDatabase/Contract/ — DbalSchemaBuilderAdapter, MysqlSchemaBuilderAdapter, SqliteSchemaBuilderAdapterXmldbSchemaAdapter— (WordPress owns its schema through dbDelta())
VersionTrackerInterfaceDatabase/Contract/ — MysqlVersionTrackerVersionTrackerVersionTracker
TransactionManagerInterfaceDatabase/Contract/ — no defaultTransactionManager—
UserContextResolverInterfaceBus/Contract/ — NullUserContextResolver; SessionUserContextResolver for session-based standalone appsMoodleUserContextWpUserContext
FormRendererInterfacemiddag-io/ui — framework provides InertiaRendererMformRenderer— (framework InertiaRenderer)
TranslatorInterfaceTranslation/Contract/ — FallbackTranslatorMoodleTranslatorWpTranslator
MailerInterfaceMail/Contract/ — NullMailer (discards)MoodleMailer (email_to_user)WpMailer (wp_mail)
FilesystemInterfaceFilesystem/Contract/ — LocalFilesystem; FlysystemFilesystem with league/flysystemMoodledataFilesystemWpUploadsFilesystem
PasswordHasherInterfaceSecurity/Contract/ — NativePasswordHasher——
ActorResolverInterface, OriginResolverInterfaceLogging/Contract/ — NullActorResolver, NullOriginResolverMoodleActorResolver, MoodleOriginResolver—
ErrorReporterInterface, ProfileCollectorInterfaceObservability/Contract/ — NullErrorReporter, ProfileCollector——

Logging and observability ports are documented in Logging & observability; database ports in Database Schema & Migrations.

Host identity in a shared process ​

HostContext::set() is a single process-wide slot that static adapter helpers outside the DI graph read through HostContext::get() (null when unset). It models one active host component per process, which holds for Moodle (one plugin boots per request). It does not hold for WordPress, where several plugins built on the WordPress adapter share one PHP process: each would overwrite the slot and the last write would win. WordPress consumers therefore never read HostContext; each plugin injects its WpComponentContext through its own container.

Transactions — the Moodle mapping ​

TransactionManagerInterface (Database/Contract/, @api) has two boundaries; the one-aggregate-per-transaction policy they serve is the framework's (FW-013). The Moodle adapter's Database\TransactionManager (@internal, bind it through the interface) maps them onto Moodle's delegated transactions:

Framework boundaryMoodle implementation
executeAtomic(callable $operation)DbSupport::startDelegatedTransaction(), run the operation, allow_commit(). On any Throwable: roll back and re-throw the original. A non-Exception Throwable is wrapped in a moodle_exception for rollback(), which only accepts exceptions; an exception thrown by rollback() itself is suppressed so the original surfaces
executeGraceful(callable $operation)Same start and commit. On failure it rolls back, then checks $DB->is_transaction_started(): if the outermost transaction has fully unwound, it returns the Throwable; if a transaction is still open (a nested boundary Moodle dooms), it re-throws, honoring Moodle's cascading rollback

Rules that apply on Moodle as on every host:

  • One aggregate per transaction. Each aggregate commits on its own; coordination between aggregates goes through signals or events with eventual consistency, never a shared transaction.
  • The only exception is a case where Moodle's own platform semantics force atomicity across the boundary, and it must be documented in code next to the call, with its justification.
  • No automated check inspects call sites for cross-aggregate repository use inside the callable yet; the rule is enforced in review.

The WordPress adapter does not implement this port.

Controller roles ​

ControllerInterface is the union of three host-neutral role interfaces, so a host composes only the roles its dispatch model runs:

Role interface (Http/Contract/)Members
ContainerAwareInterfacesetContainer()
RequestHandlingInterfacesetRequest(), preHandle(), handle()
AuthorizationAwareInterfacesetRequireLogin(), setRequireCapabilities()

interface ControllerInterface extends ContainerAwareInterface, RequestHandlingInterface, AuthorizationAwareInterface {} — the full six-method surface is unchanged for existing implementers. Smaller opt-in seams sit alongside: CapabilityRequirementAwareInterface, PublicRouteAwareInterface, AuthenticatorInterface.

PackagePage/kernel controller baseREST/API base
frameworkAbstractController (implements ControllerInterface)AbstractApiController extends AbstractController
middag-php-moodleAbstractController implements MoodleControllerInterface (which extends CapabilityRequirementAwareInterface and ControllerInterface)AbstractApiController extends AbstractController
middag-php-wordpressAbstractWpController extends the framework AbstractControllerAbstractWpRestController implements RestControllerInterface — the WordPress REST registration model (register_rest_route with a permission_callback per endpoint) has no single handle() cycle, so it shares no inheritance chain with the kernel controllers

The framework grows no WordPress-REST-shaped surface; the REST contract stays in the adapter.

Mail — the port and its value objects ​

The Mail/ concern deliberately does not depend on symfony/mailer: the framework declares only the seam and the message shape, and leaves actual delivery to the host.

  • Mail/Contract/MailerInterface — one method, send(Mail $mail): void. An implementation throws on an unrecoverable transport failure; the OSS default NullMailer discards silently and never throws (a standalone install has no MTA). Adapters map a Mail onto the platform's native sender (Moodle's email_to_user(), WordPress's wp_mail()); a product running standalone can bind a real transport (SMTP, an API) instead.
  • Mail — the immutable message value object. to/cc/bcc are list<Address>; from/replyTo are ?Address; attachments is list<Attachment>; at least one recipient is required. Every address parameter also accepts a plain string ('[email protected]', 'Jane <[email protected]>') and attachments accept a path string — both are normalised to their value-object form at construction, so every adapter always reads the same uniform shape. Template rendering stays out of the object entirely: render first (Twig, Mustache, the UI layer), then pass the result in as htmlBody.
  • Address — an email plus an optional display name, validated at construction (an empty local-part or domain, a missing @, or stray whitespace all fail). Address::parse() accepts the RFC-style "Jane Doe <[email protected]>" form; toString() quotes a display name that contains characters outside RFC 5322 atext, so a comma inside a name can never split a comma-separated recipient header.
  • Attachment — a file referenced by path, with an optional display name, MIME type and content id. Attachment::embedded($path, $contentId) creates an inline cid: part an HTML body can reference — what an HTML template needs for a logo or an inline image. Adapters map it onto whatever inline-attachment mechanism the host provides.

Observability — error reporting and profiling ports ​

Observability/ holds two ports that an adapter is not required to implement — any layer (adapter, downstream package, product, or a standalone consumer) can bind an implementation, and the OSS ships a working default either way.

  • Observability/Contract/ErrorReporterInterface — report(Throwable $throwable, array $context = []): void, the seam for shipping errors to an external tracker so kernel and domain code never depend on a vendor SDK directly. Implementations must never throw — error reporting is best-effort telemetry, not something that should ever break the request it's reporting on. OSS implementations: NullErrorReporter (the default, discards) and SentryErrorReporter — usable once the consumer installs sentry/sentry (a composer suggest, never a framework dependency). The host initialises the SDK itself; without an initialised SDK, Sentry's own capture functions are a no-op, so binding SentryErrorReporter without a DSN configured is harmless.
  • Observability/Contract/ProfileCollectorInterface — the sink for runtime profiling events: record(category, label, context, durationMs), events(), reset(). Categories are free-form strings (bus, hook, query, …). The OSS implementation ProfileCollector holds events in-memory for the lifetime of the request, plus a concrete-only byCategory() convenience method that isn't part of the contract itself. Three consumers are already wired in-tree: Bus/Middleware/ProfilingMiddleware records every dispatched message under bus (timed in a finally block, so a failed dispatch still shows up), Kernel/Manager/HookManager records fired filters/actions under hook (opt-in, via setProfileCollector()), and Http/FatalErrorHandler records shutdown fatals under fatal. No shipped adapter records under query today. Bind one collector instance and inject it everywhere to get a single timeline a dev profiler or debug bar can read back.

MIDDAG © 2015-2026