Skip to content

File Areas & Pluginfile Routing ​

Reference for declaring file areas and serving pluginfile.php requests through a typed definition and a handler contract instead of a hard-coded switch. Verified against middag-php-moodle main@6aae04d on 2026-11-04.

Who does what ​

PieceWhere
Definition\FileAreaDefinition (typed declaration, @api)This adapter
Domain\File\Contract\FileAreaHandlerInterface (handler contract, @api)This adapter
FileSupport, UrlSupport::pluginfile() (File API wrappers)This adapter
The {component}_pluginfile() callback in lib.php, the registry that maps an area to its handler, and the default handlerThe consumer plugin

Declaring a file area ​

Extensions declare file areas through getFileAreaDefinitions():

php
new FileAreaDefinition(
    name: 'attachments',
    context_level: ContextLevel::System,
    handler: AttachmentsFileAreaHandler::class,
    supports_preview: true,
    description: 'File attachments for items',
)
FieldTypePurpose
namestringArea name without extension prefix; the {filearea} segment of the URL
context_levelContextLevel (default System)Moodle context level the area is scoped to
handler?string (class implementing FileAreaHandlerInterface)Handler class; null means the consumer's default handler
supports_previewbool (default false)Whether the area supports Moodle's file preview and thumbnails
descriptionstringHuman-readable label for admin and debug tooling
min_moodle, max_moodle?stringVersion range (isCompatible())

File-area definitions are not generated into a static file: they are read at request time. Build tooling may emit documentation or validation from them, never the routing itself.

Naming ​

FileAreaDefinition::getQualifiedName($extension):

OwnerPatternExample
core extension (or none){name}attachments
Other internal extensions{extension}_{name}customdocs_templates
External plugins{plugin}_{extension}_{name} (consumer convention)middagpro_premium_reports_exports

Handler contract ​

MethodSignatureResponsibility
canAccesscanAccess(context $context, string $filearea, int $itemid, string $filepath, string $filename): boolAuthorization: return false for anything the caller cannot legitimately reach
serveserve(stored_file $file, bool $forcedownload = false): voidDelivery: call send_stored_file(), never send_file()

Request flow (consumer callback) ​

Browser
  -> /pluginfile.php/{contextid}/{component}/{filearea}/{itemid}/{path}
  -> {component}_pluginfile()             (consumer lib.php)
  -> resolve the handler for {filearea}   (consumer registry: owning extension -> container)
  -> handler->canAccess()                 (deny -> send_file_not_found / exception)
  -> handler->serve()

The registry lookup happens on every request; it is not generated at build time, unlike db/services.php.

Security requirement for consumers

The callback must call canAccess() and stop on false before it serves anything. A callback that falls back to serving a file without a handler decision (for example a generic send_file() path for undeclared areas) exposes every file in the area to any user who can guess the URL.

External plugins add their own areas through a hook the consumer exposes (the MIDDAG product uses extend_local_middag_file_areas).

FileSupport methods ​

MethodPurpose
getStorage()Moodle's file_storage
getFile(), getFileByPath(), getFileById()Fetch one stored file
getAreaFiles(), getAreaFilesTyped()All files in an area (raw or as Domain\File\StoredFile)
getFileEntity()One file as Domain\File\StoredFile
createFileFromString(), createFileFromPathname(), createFileFromStored()Create a stored file
deleteFile(), deleteFileById(), deleteAreaFiles()Delete one file or a whole area
getFileUrl()Download URL for a stored file
hasFiles(), countAreaFiles(), getAreaSize()Area inspection
isValidImage()Check that a stored file is a usable image
getContent(), getContentFileHandle()Read file content

UrlSupport::pluginfile(...) builds the pluginfile.php URL for a context, component, file area, item id, path and file name.

Anti-patterns ​

Anti-patternWhy it is wrong
canAccess() always returning trueAny user can read any file; the per-area authorization hook is defeated
Serving without calling canAccess()Same exposure, from the callback side
Business logic inside a handlerA handler delegates to the domain; it does not implement rules
send_file() instead of send_stored_file()Bypasses the File API's own headers and cache handling
An undeclared file areaThe callback finds no handler and the request fails

Decision records: MDL-008 and MDL-016 in middag-php-moodle/docs/decisions.

MIDDAG © 2015-2026