Skip to content

Services

Service ID Class Role
frontend_editing.field_reference FieldReferenceHelper Resolves field lineage and builds the action links. Aliased to FieldReferenceHelperInterface.
frontend_editing.revision_helper RevisionHelper Prepares revisions made by frontend editing and checks the moderation transition. Aliased to RevisionHelperInterface. See Revision helper.
frontend_editing.htmx_availability HtmxAvailability Detects htmx and wires elements to use it. Aliased to HtmxAvailabilityInterface. See AJAX transports.
frontend_editing.form_builder FrontendEditingFormBuilder Builds the entity form the sidebar loads
frontend_editing.toolbar_item ToolbarItem Renders the toolbar toggle, used when the floating button is off
frontend_editing.route_subscriber RouteSubscriber Route alterations
Drupal\frontend_editing\Hook\ViewAlter ViewAlter Injects the action wrappers into entity render arrays and dispatches the rendering pipeline events
Drupal\frontend_editing\Hook\FrontendEditingHooks FrontendEditingHooks Toolbar, theme, AJAX and extra field hooks
Drupal\frontend_editing\Hook\FormAlter FormAlter Form alterations, including the add-item submit path
Drupal\frontend_editing\Hook\ThirdPartySettings ThirdPartySettings The field formatter third-party settings form and summary

Field reference helper

FieldReferenceHelperInterface is the one to know. It answers where a referenced entity sits, and builds the links that act on it.

Access

accessAdd($parent_type, $parent, $parent_field_name, $current_item = NULL, $delta = NULL)
accessAddType($bundle, $parent_type, $parent, $parent_field_name, $current_item = NULL, $delta = NULL)
accessMove($parent_type, $parent, $parent_field_name, $current_item, $operation, $delta = NULL)

Mutations

move($parent_type, $parent, $parent_field_name, $current_item, $operation, $delta = NULL)
addItem($parent_type, $parent, $parent_field_name, $current_item, $before, EntityInterface $new_entity, $delta = NULL)

addItem() is the shared path behind both ways of adding: choosing a type and creating a new entity, and referencing an existing one. Both resolve the delta the same way before splicing.

Resolving a delta

resolveDelta(FieldableEntityInterface $parent_entity, $reference_field_name, $entity_id, $delta = NULL)

Reconciles the delta from the request with the host entity's current field values. The item at that delta must exist and must still reference the entity the link named. On a mismatch it returns NULL and the caller refuses the action. Omitting the delta falls back to the first item referencing that entity, so callers written against the older signature keep working.

Every mutation and its access check goes through this. A new action route that targets a specific field item should too.

buildCommonActions(EntityInterface $entity, string $viewMode, bool $addItems, bool $deleteItems, bool $moveUpDown, bool $ajaxContentUpdate, bool $duplicateItems = FALSE): array
attachActionCallbacks(array &$item, string $viewMode, EntityInterface $entity, bool $addItems, bool $deleteItems, bool $moveUpDown, bool $ajaxContentUpdate, bool $duplicateItems = FALSE): void

attachActionCallbacks() pushes the action builders onto an item's #pre_render, after checking the host entity's update access and that it is the latest revision. This is what a rendering pipeline subscriber calls.

buildCommonActions() builds the elements directly instead, for callers with no #pre_render chain to attach to.

Warning

buildCommonActions() returns ['common_actions' => [...], 'add_items' => [...]]. The two are siblings, not nested. Reading only common_actions silently drops the add links.

Neither method attaches the frontend_editing/frontend_editing library. Only the extra field's own render does. See Display Builder.

Revisions and content moderation

getRevisionRoot(EntityInterface $entity): EntityInterface
getRevisionQuery(EntityInterface $entity): array
canSaveChanges(EntityInterface $entity): bool
isLatestRevision(EntityInterface $entity): bool

getRevisionRoot() is the extension point for composite entities. Such entities are revisioned with their host, so their own revisions say nothing about whether the content is pending. The base implementation returns the entity itself. ParagraphsReferenceHelper follows the host revision the paragraph is rendered from, then the active root parent. A sub-module for another composite entity type overrides it. The main module knows nothing about composite entities itself.

isLatestRevision(), getRevisionQuery() and canSaveChanges() all work on the root:

  • isLatestRevision() is TRUE when the root is the latest revision. Actions are only attached then.
  • getRevisionQuery() returns ['default_revision' => 1] when the root is the default revision, and an empty array otherwise. The constant is FieldReferenceHelperInterface::DEFAULT_REVISION_QUERY. The move, delete and duplicate links add it to their query. FrontendEditingController::updateContent() redirects to the draft only when the parameter is present and the root has a pending revision.
  • canSaveChanges() is FALSE when the root host cannot be saved as a draft by the current user. Plain entities are saved through their own form, which validates the transition itself, so they always return TRUE.

When the root differs from the entity, attachActionCallbacks() adds the url.path cache context, because an unchanged paragraph is shared by the live page and the draft, whose links differ.

Preview

getPreviewEntity(EntityInterface $entity): ?EntityInterface
applyPreviewValue(array &$fieldBuild, string $fieldName, EntityInterface $entity, EntityInterface $previewEntity, string $viewMode): void

getPreviewEntity() answers whether a preview applies at all: the preview module installed, the type and bundle enabled, and a pending tempstore entry for this entity. Resolve it once per render and reuse it rather than asking per field.

applyPreviewValue() swaps one field's render array, guarded against re-entrant recursion, since rendering a preview value can trigger further render alters.

Both check for the preview module internally. The base module has no hard dependency on it.

Revision helper

RevisionHelperInterface makes frontend editing changes safe for content moderation.

isModerated(ContentEntityInterface $entity): bool
prepareRevision(ContentEntityInterface $entity): void
setRevisionMetadata(ContentEntityInterface $entity): void
applyDraftModerationState(ContentEntityInterface $entity): void
canSaveAsDraft(ContentEntityInterface $entity, ?AccountInterface $account = NULL): bool
canPublishDirectly(ContentEntityInterface $entity, ?AccountInterface $account = NULL): bool
setPublishDirectly(bool $publish_directly): void
isPublishingDirectly(): bool
canSaveRevision(ContentEntityInterface $entity): bool
  • prepareRevision() sets the metadata and moves a published entity into the draft state. Call it on the active revision just before saving a new revision.
  • canSaveAsDraft() checks the workflow transition with content moderation's own validation. Entities that are not moderated, or have no state yet, always pass.
  • applyDraftModerationState() throws AccessDeniedHttpException when the user may not make the transition, because saving programmatically skips the validation the entity form does.

canPublishDirectly() backs the Save and publish button. It is TRUE only for the live revision of published, moderated content with no pending revision, for an account with the frontend editing publish directly permission and a valid transition from the published state to itself. While setPublishDirectly(TRUE) is active, applyDraftModerationState() keeps the state instead of moving to draft, and throws AccessDeniedHttpException when canPublishDirectly() fails. canSaveRevision() is the check that matches the current mode, and the one the paragraphs revisioner runs before saving anything. The flag lives on the shared service for the length of the request, so the button's submit handlers turn it on and off around the save.

The target state is the workflow's default moderation state when it is unpublished, draft otherwise. The log message is fixed. To change either, replace the service in your own module.

FieldReferenceHelper receives the helper through setRevisionHelper(), called from the service definition, so the constructor signature stays stable for subclasses. A service definition that omits the call still works: the protected getRevisionHelper() falls back to the container and triggers a silent E_USER_DEPRECATED, so moderated content is never published by accident. Add the call to your own definition ([setRevisionHelper, ['@frontend_editing.revision_helper']]).

Decorating the helper

frontend_editing_paragraphs decorates FieldReferenceHelper with ParagraphsReferenceHelper, which extends the class and calls parent::__construct() positionally.

A new constructor parameter on the base class therefore needs three edits, not one: the decorator's constructor signature, its parent::__construct() call in the same position, and the decorator's own arguments list in frontend_editing_paragraphs.services.yml.

Missing one does not break drush cr, because the container still compiles. It throws a type error the first time anything resolves frontend_editing.field_reference.