fucodo / edit-helper
Context and permission aware edit/read/hide decisions per domain object field for Neos Flow
Requires
- php: ^8.2
- neos/flow: ^8.3 || ^9.0
- neos/fluid-adaptor: ^8.3 || ^9.0
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-15 12:03:13 UTC
README
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).
Installation
composer require fucodo/edit-helper
Requires 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).
Concepts
Three access levels
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()).
Strategies
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.
Binding a strategy to a class
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).
Global rules
Applied before every class strategy and can only restrict:
fucodo: EditHelper: readOnly: false # maintenance mode: everything ReadOnly globalStrategy: 'Acme\Shop\EditDecision\GuestReadOnlyStrategy'
Decision pipeline
global rules ⟶ class strategy (object level) ⟶ class strategy (field) ⟶ [dot notation: head + sub object]
each step can only lower the level; the minimum wins
Composites / dot notation
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).
Usage
Fluid
{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')}
Server side (second lock)
Templates only hide fields; the property mapper must refuse them too.
Automatic – enable the aspect once:
fucodo: EditHelper: enforceViaAop: true
EditDecisionEnforcementAspect 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.
PHP API
$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.
Configuration reference
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]
Examples
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 |
Tests
./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.
Package layout
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
Notes
-
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.