From 7d65e9186a0c5cf0eeac5623b7265bb62e0a0464 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Mon, 26 Oct 2026 11:07:28 -0600 Subject: [PATCH 1/3] Document that readOnly is only a control in the relation configuration `relationRender('comments', ['readOnly' => true])` reads as a way to forbid writes, but the value goes to the browser and comes back with each relation AJAX request, so the client decides whether it returns at all. It can only ever make a relation more restricted than its configuration says. The configuration key, and `relationExtendConfig()`, are the forms that are enforced. Also documents what a read only relation actually does now that the handlers refuse, and the preview-mode contract form widgets have to honour in their own AJAX handlers. --- backend/forms.md | 2 +- backend/relations.md | 31 ++++++++++++++++++++++++++++++- backend/widgets.md | 27 +++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/backend/forms.md b/backend/forms.md index 178f6b07..3f4a8ee4 100644 --- a/backend/forms.md +++ b/backend/forms.md @@ -1294,7 +1294,7 @@ records: Option | Description ------------- | ------------- -`readOnly` | forces the relation controller in readOnly mode. (defaults to parent previewMode) +`readOnly` | forces the relation controller in readOnly mode. (defaults to parent previewMode). This is presentational only, like the `relationRender` option it is passed to — to forbid writes, set `readOnly` in the [relation configuration](relations#read-only-relations). `recordUrl` | path to controller action to open a record (e.g. `author/plugin/controller/update/:id`). `:id` will get replaced with the record id. `recordOnClick` | custom Javascript code to execute when a record is clicked. `relation` | relation name if different from the field name. diff --git a/backend/relations.md b/backend/relations.md index 4c744831..09afab8c 100644 --- a/backend/relations.md +++ b/backend/relations.md @@ -86,7 +86,7 @@ Option | Description `manage` | configuration specific to the management popup, see below. `pivot` | a reference to form field definition file, used for [relations with pivot table data](#belongs-to-many-with-pivot-data). `emptyMessage` | a message to display when the relationship is empty, optional. -`readOnly` | disables the ability to add, update, delete or create relations. default: `false` +`readOnly` | disables the ability to add, update, delete or create relations, and is inherited by the `view` and `manage` options. Enforced server-side, so it is a safe way to forbid writes; see [Read only relations](#read-only-relations). default: `false` `deferredBinding` | [defers all binding actions using a session key](../database/relations#deferred-binding) when it is available. default: `false` These configuration values can be specified for the **view** or **manage** options, where applicable to the render type of list, form or both. @@ -106,6 +106,8 @@ Option | Type | Description `scope` | List | specifies a [query scope method](../database/model#query-scopes) defined in the **related form model** to apply to the list query always. The model that this relationship will be attached to (i.e. the **parent model**) is passed to this scope method as the second parameter (`$query` is the first). **filter** | List | a reference to a filter scopes definition file, see [backend list filters](lists#using-list-filters). +> **NOTE:** `conditions`, `scope` and the `relationExtendManageWidget()` hook narrow the *manage list* - the selection list the **Add** and **Link** buttons display - and the relation manager will only attach a record that appears in it. They are therefore a server-side restriction on what may be attached, not only on what is displayed. Everything else the relation manager operates on is resolved through the relation itself, so a record that does not belong to the relation cannot be loaded, updated, deleted or detached through it. + These configuration values can be specified only for the **view** options. Option | Type | Description @@ -332,6 +334,33 @@ You may instruct the relation manager to render in read only mode by passing the relationRender('comments', ['readOnly' => true]) ?> ``` +> **IMPORTANT**: `readOnly` passed to `relationRender` this way is **presentational only and is not an authorization control**. The value is handed to the browser and returned with each relation AJAX request, so the client decides whether it comes back at all — a request that simply omits it falls back to the relation's own configuration. It may only ever make a relation *more* restricted than its configuration; it can never relax one, and a client cannot use it to switch `readOnly` off. +> +> To actually forbid writes, set `readOnly` in the relation configuration, or set it per request from `relationExtendConfig` (see [Extending relation configuration](#extending-relation-configuration)), which runs on every request including AJAX: +> +> ```php +> public function relationExtendConfig($config, $field, $model) +> { +> if ($field === 'comments' && !BackendAuth::userHasAccess('acme.blog.manage_comments')) { +> $config->readOnly = true; +> } +> } +> ``` +> +> Both of those are enforced server-side: every relation management handler refuses with HTTP 403. + +## Read only relations + +A relation declared `readOnly` in its configuration cannot be written to at all. Every relation management handler — create, update, delete, add, remove and the pivot handlers — refuses the request with HTTP 403, drag-and-drop reordering is disabled, and the relation's forms are rendered in preview mode. + +Read only applies to writing, not to viewing: the relation still renders, and its list can still be searched, filtered, sorted and paged. + +The relation's view, manage and pivot forms are placed in preview mode, which propagates to every field widget they contain. Widgets refuse their *writing* AJAX handlers in that state — a File upload will not accept an upload, a deletion, a re-sort or a rename — because those handlers are addressed directly by widget name and would otherwise reach the relation without passing through its authorization. + +Reading is unaffected: a Markdown editor's live preview still updates, a Repeater still refreshes, lazy tabs still load and `dependsOn` fields still refresh. + +> **NOTE**: a form widget honours preview mode only if it is written to. Core widgets do; a third-party widget that does not call `abortIfPreviewMode()` in its write handlers will still write inside a read only relation. See [preview mode and AJAX handlers](widgets#preview-mode-and-ajax-handlers) if you maintain a form widget. + ## Extending relation behavior Sometimes you may wish to modify the default relation behavior and there are several ways you can do this. diff --git a/backend/widgets.md b/backend/widgets.md index 22749496..2f819a44 100644 --- a/backend/widgets.md +++ b/backend/widgets.md @@ -227,6 +227,33 @@ born_at: maxDate: 2014-04-23 ``` +### Preview mode and AJAX handlers + +A form widget carries a `previewMode` property that is `true` whenever its field is not editable. It is raised from several places and you do not set it yourself: + +- the parent form propagates its own `previewMode` to every field widget it builds, so a form rendered in a preview context raises it on all of them; +- a field declared `disabled` raises it on that field's widget; +- a [read only relation](relations#read-only-relations) raises it on the widgets in its view, manage and pivot forms. + +Widgets already honour `previewMode` when they render, by hiding the controls that would change anything. **A widget's AJAX handlers must honour it too.** A handler is dispatched straight to the widget by its alias — `mywidget::onDoSomething` — so it does not pass through whatever authorized the page, and a handler that writes will happily write to a field the page is showing as uneditable. + +Any handler that changes something must therefore refuse when the widget is in preview mode. `Backend\Classes\FormWidgetBase` provides the guard: + +```php +public function onDeleteSomething() +{ + $this->abortIfPreviewMode(); + + // ... +} +``` + +`abortIfPreviewMode()` aborts the request with a `403` when `previewMode` is set, and does nothing otherwise. + +Handlers that only read should **not** call it — a preview form is still meant to be looked at, so refreshing a preview, loading a library or rendering a picker must keep working. The core widgets follow this rule: `FileUpload::onUpload`, `onRemoveAttachment`, `onSortAttachments` and `onSaveAttachmentConfig` are guarded while `onLoadAttachmentConfig` is not; `Repeater::onAddItem` and `onRemoveItem` are guarded while `onRefresh` is not; `MarkdownEditor::onRefresh`, `RichEditor::onLoadPageLinksForm` and `IconPicker::onLoadIconLibrary` are reads and are left alone. + +> **NOTE**: this is a contract, not an enforcement — nothing stops a widget from ignoring `previewMode`. A third-party widget that does not call `abortIfPreviewMode()` in its write handlers remains writable on a preview form, just as it already renders its editable controls there. + ### Form widget registration Plugins should register form widgets by overriding the `registerFormWidgets` method inside the [Plugin registration class](../plugin/registration#registration-file). The method returns an array containing the widget class in the keys and widget short code as the value. Example: From 20638be2d4db73dc3eb1b99c9c3b4eba0103e2c5 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Mon, 26 Oct 2026 11:15:11 -0600 Subject: [PATCH 2/3] Trim the prose --- backend/forms.md | 2 +- backend/relations.md | 29 ++++++++--------------------- backend/widgets.md | 16 +++++----------- 3 files changed, 14 insertions(+), 33 deletions(-) diff --git a/backend/forms.md b/backend/forms.md index 3f4a8ee4..2600150d 100644 --- a/backend/forms.md +++ b/backend/forms.md @@ -1294,7 +1294,7 @@ records: Option | Description ------------- | ------------- -`readOnly` | forces the relation controller in readOnly mode. (defaults to parent previewMode). This is presentational only, like the `relationRender` option it is passed to — to forbid writes, set `readOnly` in the [relation configuration](relations#read-only-relations). +`readOnly` | forces the relation controller in readOnly mode. (defaults to parent previewMode). Presentational only - to forbid writes, set `readOnly` in the [relation configuration](relations#read-only-relations). `recordUrl` | path to controller action to open a record (e.g. `author/plugin/controller/update/:id`). `:id` will get replaced with the record id. `recordOnClick` | custom Javascript code to execute when a record is clicked. `relation` | relation name if different from the field name. diff --git a/backend/relations.md b/backend/relations.md index 09afab8c..9fcc81d9 100644 --- a/backend/relations.md +++ b/backend/relations.md @@ -86,7 +86,7 @@ Option | Description `manage` | configuration specific to the management popup, see below. `pivot` | a reference to form field definition file, used for [relations with pivot table data](#belongs-to-many-with-pivot-data). `emptyMessage` | a message to display when the relationship is empty, optional. -`readOnly` | disables the ability to add, update, delete or create relations, and is inherited by the `view` and `manage` options. Enforced server-side, so it is a safe way to forbid writes; see [Read only relations](#read-only-relations). default: `false` +`readOnly` | forbids adding, updating, deleting and creating, and is enforced server-side. See [Read only relations](#read-only-relations). default: `false` `deferredBinding` | [defers all binding actions using a session key](../database/relations#deferred-binding) when it is available. default: `false` These configuration values can be specified for the **view** or **manage** options, where applicable to the render type of list, form or both. @@ -106,7 +106,7 @@ Option | Type | Description `scope` | List | specifies a [query scope method](../database/model#query-scopes) defined in the **related form model** to apply to the list query always. The model that this relationship will be attached to (i.e. the **parent model**) is passed to this scope method as the second parameter (`$query` is the first). **filter** | List | a reference to a filter scopes definition file, see [backend list filters](lists#using-list-filters). -> **NOTE:** `conditions`, `scope` and the `relationExtendManageWidget()` hook narrow the *manage list* - the selection list the **Add** and **Link** buttons display - and the relation manager will only attach a record that appears in it. They are therefore a server-side restriction on what may be attached, not only on what is displayed. Everything else the relation manager operates on is resolved through the relation itself, so a record that does not belong to the relation cannot be loaded, updated, deleted or detached through it. +> **NOTE:** `conditions`, `scope` and `relationExtendManageWidget()` narrow the manage list that **Add** and **Link** select from, and only a record appearing in it may be attached - so they restrict what may be attached, not just what is shown. Every other record the relation manager touches is resolved through the relation itself. These configuration values can be specified only for the **view** options. @@ -334,32 +334,19 @@ You may instruct the relation manager to render in read only mode by passing the relationRender('comments', ['readOnly' => true]) ?> ``` -> **IMPORTANT**: `readOnly` passed to `relationRender` this way is **presentational only and is not an authorization control**. The value is handed to the browser and returned with each relation AJAX request, so the client decides whether it comes back at all — a request that simply omits it falls back to the relation's own configuration. It may only ever make a relation *more* restricted than its configuration; it can never relax one, and a client cannot use it to switch `readOnly` off. +> **IMPORTANT**: this is presentational, not an authorization control. The value travels to the browser and back with each AJAX request, so a request that omits it falls back to the relation's configuration. It can only make a relation *more* restricted, never less. > -> To actually forbid writes, set `readOnly` in the relation configuration, or set it per request from `relationExtendConfig` (see [Extending relation configuration](#extending-relation-configuration)), which runs on every request including AJAX: -> -> ```php -> public function relationExtendConfig($config, $field, $model) -> { -> if ($field === 'comments' && !BackendAuth::userHasAccess('acme.blog.manage_comments')) { -> $config->readOnly = true; -> } -> } -> ``` -> -> Both of those are enforced server-side: every relation management handler refuses with HTTP 403. +> To forbid writes, set `readOnly` in the relation configuration, or per request from [`relationExtendConfig()`](#extending-relation-configuration). Both are enforced server-side. ## Read only relations -A relation declared `readOnly` in its configuration cannot be written to at all. Every relation management handler — create, update, delete, add, remove and the pivot handlers — refuses the request with HTTP 403, drag-and-drop reordering is disabled, and the relation's forms are rendered in preview mode. - -Read only applies to writing, not to viewing: the relation still renders, and its list can still be searched, filtered, sorted and paged. +Every relation management handler - create, update, delete, add, remove and the pivot handlers - refuses with HTTP 403, drag-and-drop reordering is disabled, and the view, manage and pivot forms render in preview mode. -The relation's view, manage and pivot forms are placed in preview mode, which propagates to every field widget they contain. Widgets refuse their *writing* AJAX handlers in that state — a File upload will not accept an upload, a deletion, a re-sort or a rename — because those handlers are addressed directly by widget name and would otherwise reach the relation without passing through its authorization. +Only writing is forbidden. The relation still renders and its list can still be searched, filtered, sorted and paged. -Reading is unaffected: a Markdown editor's live preview still updates, a Repeater still refreshes, lazy tabs still load and `dependsOn` fields still refresh. +Preview mode propagates to every field widget in those forms, so a widget's own write handlers refuse too - they are addressed by widget alias and would otherwise reach the relation without passing through its authorization. -> **NOTE**: a form widget honours preview mode only if it is written to. Core widgets do; a third-party widget that does not call `abortIfPreviewMode()` in its write handlers will still write inside a read only relation. See [preview mode and AJAX handlers](widgets#preview-mode-and-ajax-handlers) if you maintain a form widget. +> **NOTE**: a third-party widget that does not call `abortIfPreviewMode()` in its write handlers will still write inside a read only relation. See [preview mode and AJAX handlers](widgets#preview-mode-and-ajax-handlers) if you maintain one. ## Extending relation behavior diff --git a/backend/widgets.md b/backend/widgets.md index 2f819a44..edbc761d 100644 --- a/backend/widgets.md +++ b/backend/widgets.md @@ -229,15 +229,9 @@ born_at: ### Preview mode and AJAX handlers -A form widget carries a `previewMode` property that is `true` whenever its field is not editable. It is raised from several places and you do not set it yourself: +A form widget carries a `previewMode` property that is `true` whenever its field is not editable. You do not set it yourself; it is raised by the parent form on every field widget it builds, by a `disabled` field, and by a [read only relation](relations#read-only-relations) on its forms. -- the parent form propagates its own `previewMode` to every field widget it builds, so a form rendered in a preview context raises it on all of them; -- a field declared `disabled` raises it on that field's widget; -- a [read only relation](relations#read-only-relations) raises it on the widgets in its view, manage and pivot forms. - -Widgets already honour `previewMode` when they render, by hiding the controls that would change anything. **A widget's AJAX handlers must honour it too.** A handler is dispatched straight to the widget by its alias — `mywidget::onDoSomething` — so it does not pass through whatever authorized the page, and a handler that writes will happily write to a field the page is showing as uneditable. - -Any handler that changes something must therefore refuse when the widget is in preview mode. `Backend\Classes\FormWidgetBase` provides the guard: +Widgets already hide their controls in that state, but a handler is dispatched straight to the widget by alias - `mywidget::onDoSomething` - so it never passes through whatever authorized the page. **Any handler that writes must refuse:** ```php public function onDeleteSomething() @@ -248,11 +242,11 @@ public function onDeleteSomething() } ``` -`abortIfPreviewMode()` aborts the request with a `403` when `previewMode` is set, and does nothing otherwise. +`abortIfPreviewMode()` aborts with a `403` when `previewMode` is set, and does nothing otherwise. -Handlers that only read should **not** call it — a preview form is still meant to be looked at, so refreshing a preview, loading a library or rendering a picker must keep working. The core widgets follow this rule: `FileUpload::onUpload`, `onRemoveAttachment`, `onSortAttachments` and `onSaveAttachmentConfig` are guarded while `onLoadAttachmentConfig` is not; `Repeater::onAddItem` and `onRemoveItem` are guarded while `onRefresh` is not; `MarkdownEditor::onRefresh`, `RichEditor::onLoadPageLinksForm` and `IconPicker::onLoadIconLibrary` are reads and are left alone. +Handlers that only read must **not** call it - a preview form is still meant to be looked at, so refreshing a preview or loading a picker keeps working. Core widgets follow this: `FileUpload::onUpload` and `Repeater::onAddItem` are guarded, `FileUpload::onLoadAttachmentConfig` and `MarkdownEditor::onRefresh` are not. -> **NOTE**: this is a contract, not an enforcement — nothing stops a widget from ignoring `previewMode`. A third-party widget that does not call `abortIfPreviewMode()` in its write handlers remains writable on a preview form, just as it already renders its editable controls there. +> **NOTE**: this is a contract, not an enforcement. A widget that ignores `previewMode` stays writable on a preview form, just as it already renders its editable controls there. ### Form widget registration From 5a710845b5be3268969781dcdb368234f6c8cf31 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Mon, 26 Oct 2026 11:17:04 -0600 Subject: [PATCH 3/3] Correct what a relationRender readOnly actually does applyExtraConfig() keeps only recordUrl and recordOnClick, so the value is dropped in both directions rather than restricting the render further. --- backend/relations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/relations.md b/backend/relations.md index 9fcc81d9..16a57a8b 100644 --- a/backend/relations.md +++ b/backend/relations.md @@ -334,7 +334,7 @@ You may instruct the relation manager to render in read only mode by passing the relationRender('comments', ['readOnly' => true]) ?> ``` -> **IMPORTANT**: this is presentational, not an authorization control. The value travels to the browser and back with each AJAX request, so a request that omits it falls back to the relation's configuration. It can only make a relation *more* restricted, never less. +> **IMPORTANT**: this styles that one render and nothing else. It is not an authorization control and is not remembered: the relation's AJAX handlers read `readOnly` from the relation configuration alone, so passing it here neither forbids a write nor allows one. > > To forbid writes, set `readOnly` in the relation configuration, or per request from [`relationExtendConfig()`](#extending-relation-configuration). Both are enforced server-side.