fucodo/edit-helper — context and permission aware edit decisions for Neos Flow.
Answers one question in one place: may the current user see or edit this field of this object? The answer is used by Fluid view helpers (rendering), by the property mapper (server-side lock) and by controllers (403).
composer require fucodo/edit-helperRequires Neos Flow 8.3+ and PHP 8.2+. neos/fluid-adaptor is required for the view helpers;
the service, the guard and the aspect work without Fluid, so a non-Fluid application can drop
that dependency and simply never load Classes/ViewHelpers.
The package is independent of fucodo/bootstrap — nothing in it emits framework-specific
markup. Fluid namespaces are declared per template, so a Bootstrap-based project just writes
{namespace ed=fucodo\EditHelper\ViewHelpers} (or maps a prefix to several PHP namespaces
in Neos.FluidAdaptor.namespaces).
Hidden < ReadOnly < Editable
FieldAccess is a hierarchical enum: Editable implies readable, Hidden implies not editable.
There is no "editable but invisible" state, so a single decision covers visibility and editability;
the view helpers just look at it from different angles.
FieldDecision wraps the level with an optional human-readable reason (shown as title, hint text, 403 message).
Combining decisions always keeps the more restrictive one (restrictBy()).
A strategy decides for one class:
interface EditDecisionStrategyInterface
{
public function decide(object $subject, ?string $property, EditContext $context): FieldDecision;
}$property === null asks for the object-level upper bound; every field decision is capped by it.
EditContext carries the security context, the current ActionRequest (when known) and free-form extras.
Two base classes cover most cases:
| Base class | Style | Use when |
|---|---|---|
AbstractEditDecisionStrategy |
deny-list: override decideObject() and decideProperty() with a match, return null to fall back to the object level |
most fields are editable, some exceptions |
AbstractAllowListEditDecisionStrategy |
allow-list: return the editable paths from editableProperties(), everything else gets fallback() (default ReadOnly); optional hiddenProperties() |
only a few fields may be edited |
Allow-list path syntax: name, address.street, address.* (one level), address.** (any depth), *, **.
A parent path (address) is implicitly editable when any listed path starts with it.
Resolution order, most specific class first, walking up the inheritance chain:
Settings.yaml→fucodo.EditHelper.strategies.<FQCN>#[EditDecision(strategy: …)]attribute on the class (or a parent)defaultStrategyfrom settings (everything editable)
#[Flow\Entity]
#[EditDecision(strategy: InvoiceEditStrategy::class)]
class Invoice { … }fucodo:
EditHelper:
strategies:
'Acme\Shop\Domain\Model\Customer': 'Acme\Shop\EditDecision\CustomerEditStrategy'The attribute of a subclass replaces the parent's strategy – it can therefore lift parent restrictions.
If you want to inherit rules and lift only some, extend the parent strategy and call parent::…
(see Documentation/Examples/EditDecision/DraftInvoiceEditStrategy.php).
Applied before every class strategy and can only restrict:
fucodo:
EditHelper:
readOnly: false # maintenance mode: everything ReadOnly
globalStrategy: 'Acme\Shop\EditDecision\GuestReadOnlyStrategy'global rules ⟶ class strategy (object level) ⟶ class strategy (field) ⟶ [dot notation: head + sub object]
each step can only lower the level; the minimum wins
decide($invoice, 'billingAddress.street') is the minimum of
- the Invoice strategy asked for
billingAddress.street, - the Invoice strategy asked for
billingAddress(so a read-only composite locks all sub fields), - the Address strategy asked for
streeton the actualAddressobject (resolved with its own attribute/settings).
If the sub object is null, the last step is skipped. Any depth works (a.b.c).
{namespace ed=fucodo\EditHelper\ViewHelpers}
<!-- render only if editable -->
<ed:field.if object="{invoice}" property="amount">…</ed:field.if>
<!-- render if at least readable, with then/else -->
<ed:field.if object="{invoice}" property="amount" atLeast="ReadOnly">
<f:then>…</f:then><f:else>…</f:else>
</ed:field.if>
<!-- object level -->
<ed:field.if object="{invoice}"><f:form.submit value="Save" /></ed:field.if>
<!-- readonly/disabled + data-no-edit + title=reason for form fields -->
<f:form.textfield property="amount"
additionalAttributes="{ed:field.attributes(object: invoice, property: 'amount')}" />
<f:form.textfield property="billingAddress.city"
additionalAttributes="{ed:field.attributes(object: invoice, property: 'billingAddress.city', mode: 'disabled')}" />
<!-- value only when readable -->
{ed:field.value(object: invoice, property: 'amount')}
<ed:field.value object="{invoice}" property="amount" hiddenPlaceholder="–">{invoice.amount -> f:format.currency()}</ed:field.value>
<!-- level name and reason -->
{ed:field.access(object: invoice, property: 'amount')} → Hidden | ReadOnly | Editable
{ed:field.reason(object: invoice, property: 'amount')}Templates only hide fields; the property mapper must refuse them too.
Automatic – enable the aspect once:
fucodo:
EditHelper:
enforceViaAop: trueEditDecisionEnforcementAspect then, for every ActionController and every entity argument
carrying __identity:
- throws
AccessDeniedException(403) when the object is not editable at all, - registers
skipProperties()for everyReadOnly/Hiddenpath (includingforProperty('address')->skipProperties('country')), - passes the current request into
EditContext.
Manual – per controller:
protected function initializeUpdateAction(): void
{
$invoice = $this->invoiceRepository->findByIdentifier($this->request->getArgument('invoice')['__identity'] ?? '');
if ($invoice && !$this->editDecisionService->isEditable($invoice)) {
$this->throwStatus(403);
}
if ($invoice) {
$this->propertyMappingGuard->protectArgument($this->arguments['invoice'], $invoice);
}
}PropertyMappingGuard::protect() discovers properties via reflection and recurses into editable
sub objects (maxDepth, default 2); pass an explicit path list to control it.
$service->decide($object, 'amount') // FieldDecision (access + reason)
$service->access($object, 'address.zip') // FieldAccess
$service->isEditable($object) // object level
$service->isReadable($object, 'notes')
$service->accessMap($object, ['a', 'b.c']) // ['a' => FieldAccess, …]
$service->filterEditable($object, [...]) // editable subset
$service->createContext(['workflow' => 'review']) // custom EditContext for decide(…, $context)Decisions are cached per object and property for the lifetime of the service (request) when no custom
context is passed; flushDecisionCache() resets it.
fucodo:
EditHelper:
enforceViaAop: false # activate EditDecisionEnforcementAspect
readOnly: false # force ReadOnly for everything
globalStrategy: null # strategy class applied to every subject first
defaultStrategy: 'fucodo\EditHelper\EditDecision\DefaultEditDecisionStrategy'
strategies: {} # FQCN → strategy FQCN, overrides #[EditDecision]Documentation/Examples/ contains a small Acme.Shop package:
| File | Shows |
|---|---|
Domain/Invoice.php, EditDecision/InvoiceEditStrategy.php |
attribute binding, match style deny-list, role and state checks, read-only composite |
Domain/DraftInvoice.php, EditDecision/DraftInvoiceEditStrategy.php |
inheritance: subclass strategy lifts parent rules explicitly |
Domain/Address.php, EditDecision/AddressEditStrategy.php |
composite with its own strategy, used via dot notation |
Domain/Customer.php, EditDecision/CustomerEditStrategy.php, Settings.yaml |
allow-list strategy, binding via settings, hidden fields per role |
EditDecision/GuestReadOnlyStrategy.php |
global strategy |
Controller/InvoiceController.php |
manual guard without AOP |
Templates/Invoice/Edit.html, Show.html |
all view helpers, including dot notation |
./bin/phpunit -c Build/BuildEssentials/PhpUnit/UnitTests.xml \
Packages/Application/fucodo.EditHelper/Tests/Unit
79 unit tests in Tests/Unit:
| Test | Covers |
|---|---|
EditDecision/FieldAccessTest |
level order, min(), atLeast(), fromName() incl. rejection |
EditDecision/FieldDecisionTest |
factories, restrictBy() (never raises, keeps reason, adopts a missing one) |
EditDecision/EditContextTest |
role/authentication delegation, immutable extras |
EditDecision/AbstractEditDecisionStrategyTest |
object level vs. field level, null fallback, subclass lifting parent rules |
EditDecision/AbstractAllowListEditDecisionStrategyTest |
allow list, custom fallback, hidden beats editable, all wildcard patterns (data provider) |
EditDecision/EditDecisionServiceTest |
strategy resolution (attribute, inheritance, settings precedence, default, errors), global rules, dot notation incl. head capping and null composites, per-object caching and flush, accessMap()/filterEditable() |
EditDecision/PropertyMappingGuardTest |
skipProperties() for flat and nested paths, property discovery with depth limit, no recursion into locked composites |
ViewHelpers/Field/* |
rendered attributes per level and mode, level/reason output, value rendering with placeholder and tag content, condition view helper with atLeast |
Fixtures in Tests/Unit/Fixtures provide models carrying real #[EditDecision] attributes, a
recording strategy for call-count assertions and lightweight stand-ins for the security and
reflection services, so the tests need no database and no Flow bootstrap beyond the unit test base.
composer.json
Classes/
Annotations/EditDecision.php #[EditDecision(strategy: …)]
Aspect/EditDecisionEnforcementAspect.php optional AOP enforcement
EditDecision/
FieldAccess.php, FieldDecision.php, EditContext.php
EditDecisionStrategyInterface.php
AbstractEditDecisionStrategy.php deny-list base
AbstractAllowListEditDecisionStrategy.php allow-list base
DefaultEditDecisionStrategy.php
EditDecisionService.php single entry point
PropertyMappingGuard.php skipProperties() helper
ViewHelpers/Field/ if, attributes, value, access, reason
Configuration/Settings.yaml, Objects.yaml
Documentation/Examples/
Tests/Unit/
EditDecision/ value objects, strategies, service, guard
ViewHelpers/Field/ view helper rendering
Fixtures/ models with attributes, strategies, fakes
phpunit.xml.dist
-
Settings live directly under
fucodo.EditHelper(no nestededitDecisionlevel); the service reads them via#[Flow\InjectConfiguration(package: 'fucodo.EditHelper')]and the AOP pointcut usessetting(fucodo.EditHelper.enforceViaAop). -
IfViewHelperdisables Fluid template compilation for itself (compile()calls$compiler->disable()) because the verdict depends on injected services. -
EditDecisionService::classHierarchy()ignores Flow's*_Originalproxy parents. -
The aspect skips new objects (no
__identity), composite identities and non-entity arguments. -
Keep UX-driven hiding (a field that is irrelevant in a workflow state) out of the strategies;
Hiddenis a permission level and is enforced server-side.