waaseyaa / workflows
Content moderation and editorial workflow states for Waaseyaa
Requires
- php: >=8.5
- waaseyaa/access: ^0.1.0-alpha.300
- waaseyaa/audit: ^0.1.0-alpha.300
- waaseyaa/config: ^0.1.0-alpha.300
- waaseyaa/entity: ^0.1.0-alpha.300
- waaseyaa/entity-storage: ^0.1.0-alpha.300
- waaseyaa/field: ^0.1.0-alpha.300
- waaseyaa/foundation: ^0.1.0-alpha.300
- waaseyaa/groups: ^0.1.0-alpha.300
- waaseyaa/publishing: ^0.1.0-alpha.300
- waaseyaa/relationship: ^0.1.0-alpha.300
Requires (Dev)
- phpunit/phpunit: ^13.0
- waaseyaa/database-legacy: ^0.1.0-alpha.300
- waaseyaa/node: ^0.1.0-alpha.300
- waaseyaa/user: ^0.1.0-alpha.300
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main / 0.1.x-dev
- v0.1.0-alpha.300
- v0.1.0-alpha.299
- v0.1.0-alpha.298
- v0.1.0-alpha.297
- v0.1.0-alpha.296
- v0.1.0-alpha.295
- v0.1.0-alpha.294
- v0.1.0-alpha.293
- v0.1.0-alpha.292
- v0.1.0-alpha.291
- v0.1.0-alpha.290
- v0.1.0-alpha.289
- v0.1.0-alpha.288
- v0.1.0-alpha.287
- v0.1.0-alpha.286
- v0.1.0-alpha.285
- v0.1.0-alpha.284
- v0.1.0-alpha.283
- v0.1.0-alpha.282
- v0.1.0-alpha.281
- v0.1.0-alpha.280
- v0.1.0-alpha.279
- v0.1.0-alpha.278
- v0.1.0-alpha.277
- v0.1.0-alpha.276
- v0.1.0-alpha.275
- v0.1.0-alpha.274
- v0.1.0-alpha.273
- v0.1.0-alpha.272
- v0.1.0-alpha.271
- v0.1.0-alpha.270
- v0.1.0-alpha.269
- v0.1.0-alpha.268
- v0.1.0-alpha.267
- v0.1.0-alpha.266
- v0.1.0-alpha.265
- v0.1.0-alpha.264
- v0.1.0-alpha.263
- v0.1.0-alpha.262
- v0.1.0-alpha.261
- v0.1.0-alpha.260
- v0.1.0-alpha.259
- v0.1.0-alpha.258
- v0.1.0-alpha.257
- v0.1.0-alpha.256
- v0.1.0-alpha.255
- v0.1.0-alpha.254
- v0.1.0-alpha.253
- v0.1.0-alpha.252
- v0.1.0-alpha.251
- v0.1.0-alpha.250
- v0.1.0-alpha.249
- v0.1.0-alpha.248
- v0.1.0-alpha.247
- v0.1.0-alpha.246
- v0.1.0-alpha.245
- v0.1.0-alpha.244
- v0.1.0-alpha.243
- v0.1.0-alpha.242
- v0.1.0-alpha.241
- v0.1.0-alpha.240
- v0.1.0-alpha.239
- v0.1.0-alpha.238
- v0.1.0-alpha.237
- v0.1.0-alpha.236
- v0.1.0-alpha.235
- v0.1.0-alpha.234
- v0.1.0-alpha.233
- v0.1.0-alpha.232
- v0.1.0-alpha.231
- v0.1.0-alpha.230
- v0.1.0-alpha.229
- v0.1.0-alpha.228
- v0.1.0-alpha.227
- v0.1.0-alpha.226
- v0.1.0-alpha.225
- v0.1.0-alpha.224
- v0.1.0-alpha.223
- v0.1.0-alpha.222
- v0.1.0-alpha.221
- v0.1.0-alpha.220
- v0.1.0-alpha.219
- v0.1.0-alpha.218
- v0.1.0-alpha.217
- v0.1.0-alpha.216
- v0.1.0-alpha.215
- v0.1.0-alpha.214
- v0.1.0-alpha.213
- v0.1.0-alpha.212
- v0.1.0-alpha.211
- v0.1.0-alpha.210
- v0.1.0-alpha.209
- v0.1.0-alpha.208
- v0.1.0-alpha.207
- v0.1.0-alpha.206
- v0.1.0-alpha.205
- v0.1.0-alpha.204
- v0.1.0-alpha.203
- v0.1.0-alpha.202
- v0.1.0-alpha.201
- v0.1.0-alpha.200
- v0.1.0-alpha.199
- v0.1.0-alpha.198
- v0.1.0-alpha.197
- v0.1.0-alpha.196
- v0.1.0-alpha.195
- v0.1.0-alpha.194
- v0.1.0-alpha.193
- v0.1.0-alpha.192
- v0.1.0-alpha.191
- v0.1.0-alpha.190
- v0.1.0-alpha.189
- v0.1.0-alpha.188
- v0.1.0-alpha.187
- v0.1.0-alpha.186
- v0.1.0-alpha.185
- v0.1.0-alpha.184
- v0.1.0-alpha.183
- v0.1.0-alpha.182
- v0.1.0-alpha.181
- v0.1.0-alpha.180
- v0.1.0-alpha.179
- v0.1.0-alpha.178
- v0.1.0-alpha.177
- v0.1.0-alpha.176
- v0.1.0-alpha.175
- v0.1.0-alpha.174
- v0.1.0-alpha.173
- v0.1.0-alpha.172
- v0.1.0-alpha.171
- v0.1.0-alpha.170
- v0.1.0-alpha.169
- v0.1.0-alpha.168
- v0.1.0-alpha.167
- v0.1.0-alpha.166
- v0.1.0-alpha.165
- v0.1.0-alpha.164
- v0.1.0-alpha.163
- v0.1.0-alpha.162
- v0.1.0-alpha.161
- v0.1.0-alpha.160
- v0.1.0-alpha.159
- v0.1.0-alpha.158
- v0.1.0-alpha.157
- v0.1.0-alpha.156
- v0.1.0-alpha.155
- v0.1.0-alpha.154
- v0.1.0-alpha.153
- v0.1.0-alpha.152
- v0.1.0-alpha.151
- v0.1.0-alpha.150
- v0.1.0-alpha.149
- v0.1.0-alpha.148
- v0.1.0-alpha.147
- v0.1.0-alpha.146
- v0.1.0-alpha.145
- v0.1.0-alpha.144
- v0.1.0-alpha.143
- v0.1.0-alpha.141
- v0.1.0-alpha.140
- v0.1.0-alpha.139
- v0.1.0-alpha.138
- v0.1.0-alpha.137
- v0.1.0-alpha.136
- v0.1.0-alpha.135
- v0.1.0-alpha.134
- v0.1.0-alpha.133
- v0.1.0-alpha.132
- v0.1.0-alpha.131
- v0.1.0-alpha.130
- v0.1.0-alpha.129
- v0.1.0-alpha.128
- v0.1.0-alpha.127
- v0.1.0-alpha.126
- v0.1.0-alpha.125
- v0.1.0-alpha.124
- v0.1.0-alpha.123
- v0.1.0-alpha.122
- v0.1.0-alpha.121
- v0.1.0-alpha.120
- v0.1.0-alpha.119
- v0.1.0-alpha.118
- v0.1.0-alpha.117
- v0.1.0-alpha.116
- v0.1.0-alpha.115
- v0.1.0-alpha.114
- v0.1.0-alpha.113
- v0.1.0-alpha.112
- v0.1.0-alpha.111
- v0.1.0-alpha.110
- v0.1.0-alpha.109
- v0.1.0-alpha.108
- v0.1.0-alpha.107
- v0.1.0-alpha.106
- v0.1.0-alpha.105
- v0.1.0-alpha.104
- v0.1.0-alpha.103
- v0.1.0-alpha.102
- v0.1.0-alpha.101
- v0.1.0-alpha.100
- v0.1.0-alpha.99
- v0.1.0-alpha.98
- v0.1.0-alpha.97
- v0.1.0-alpha.96
- v0.1.0-alpha.95
- v0.1.0-alpha.94
- v0.1.0-alpha.93
- v0.1.0-alpha.92
- v0.1.0-alpha.91
- v0.1.0-alpha.90
- v0.1.0-alpha.89
- v0.1.0-alpha.88
- v0.1.0-alpha.87
- v0.1.0-alpha.86
- v0.1.0-alpha.85
- v0.1.0-alpha.84
- v0.1.0-alpha.83
- v0.1.0-alpha.82
- v0.1.0-alpha.81
- v0.1.0-alpha.80
- v0.1.0-alpha.79
- v0.1.0-alpha.78
- v0.1.0-alpha.77
- v0.1.0-alpha.76
- v0.1.0-alpha.75
- v0.1.0-alpha.74
- v0.1.0-alpha.73
- v0.1.0-alpha.72
- v0.1.0-alpha.71
- v0.1.0-alpha.70
- v0.1.0-alpha.69
- v0.1.0-alpha.68
- v0.1.0-alpha.67
- v0.1.0-alpha.66
- v0.1.0-alpha.65
- v0.1.0-alpha.64
- v0.1.0-alpha.63
- v0.1.0-alpha.62
- v0.1.0-alpha.61
- v0.1.0-alpha.58
- v0.1.0-alpha.57
- v0.1.0-alpha.56
- v0.1.0-alpha.55
- v0.1.0-alpha.54
- v0.1.0-alpha.53
- v0.1.0-alpha.52
- v0.1.0-alpha.51
- v0.1.0-alpha.50
- v0.1.0-alpha.49
- v0.1.0-alpha.48
- v0.1.0-alpha.47
- v0.1.0-alpha.46
- v0.1.0-alpha.45
- v0.1.0-alpha.44
- v0.1.0-alpha.43
- v0.1.0-alpha.42
- v0.1.0-alpha.41
- v0.1.0-alpha.40
- v0.1.0-alpha.39
- v0.1.0-alpha.38
- v0.1.0-alpha.37
- v0.1.0-alpha.36
- v0.1.0-alpha.35
- v0.1.0-alpha.34
- v0.1.0-alpha.33
- v0.1.0-alpha.32
- v0.1.0-alpha.31
- v0.1.0-alpha.30
- v0.1.0-alpha.29
- v0.1.0-alpha.28
- v0.1.0-alpha.27
- v0.1.0-alpha.26
- v0.1.0-alpha.25
- v0.1.0-alpha.24
- v0.1.0-alpha.23
- v0.1.0-alpha.22
- v0.1.0-alpha.21
- v0.1.0-alpha.20
- v0.1.0-alpha.19
- v0.1.0-alpha.18
- v0.1.0-alpha.17
- v0.1.0-alpha.16
- v0.1.0-alpha.15
- v0.1.0-alpha.14
- v0.1.0-alpha.13
- v0.1.0-alpha.12
- v0.1.0-alpha.11
- v0.1.0-alpha.10
- v0.1.0-alpha.9
- v0.1.0-alpha.8
- v0.1.0-alpha.7
- v0.1.0-alpha.6
- v0.1.0-alpha.5
- v0.1.0-alpha.4
- v0.1.0-alpha.3
- v0.1.0-alpha.2
- v0.1.0-alpha.1
This package is auto-updated.
Last update: 2026-09-02 16:18:49 UTC
README
Layer 3 — Services
Content moderation and editorial workflow states for Waaseyaa applications.
CW-v1 engine (WP-1 + WP-2, live)
The content-workflow engine (docs/specs/content-workflow.md) is now the canonical surface: named
editorial states, permission-gated transitions, enforced in the write path, with transition audit
history. Workflows are config, not code — the default editorial workflow ships as declarative
seed data (DefaultWorkflows::EDITORIAL), not a hardcoded preset class.
-
Workflow/WorkflowState/WorkflowTransition— the config-entity primitives. States now carrypublished/defaultRevisionflags; transitions carry an explicit or derivedpermissionstring (use {workflow_id} transition {transition_id}); the workflow itself carries aninitial_state. -
Validation\WorkflowValidator— structural checks (unknown states infrom/to, unknowninitial_state, zero states) used at seed/import time. -
Binding\WorkflowBindingResolver— resolves the workflow bound to an entity type + bundle via theworkflows.assignmentsconfig ({type}.{bundle}exact key wins over{type}.*wildcard); throws for a non-revisionable bound type or an unknown workflow id. -
Transition\TransitionService— the one enforcement door:transition()validates (binding exists, transition exists, current state is a legalfrom, account holds the permission) → applies (workflow_state+statusper the target state'spublishedflag) → persists throughEntityTypeManagerInterface::getRepository()->save()(never a direct storage write) → announces (WorkflowEvents::PRE_TRANSITION/POST_TRANSITION) → records a best-effort audit entry (AuditEventKind::WorkflowTransition). Denials throwTransition\TransitionDeniedExceptionwith a machine-readablereason(unbound,unknown_transition,illegal_edge,permission) — never a silent no-op.getAvailableTransitions()is the sanctioned read-side for UIs. -
Listener\WorkflowStateGuard— aEntityEvents::PRE_SAVEsubscriber that makes the raw entity save path equivalent toTransitionService: a create is forced intoinitial_stateunless the acting account can reach a different state via a single legal + permitted transition (closing the born-published hole); an update'sworkflow_statechange is validated exactly like a transition (permission required whenever an actingAccountContextInterfacecontext exists; a null context — CLI/queue/programmatic — checks edge-legality only). -
WorkflowServiceProvider::boot()wires both guards below onto the real dispatcher (Symfony-contracts FQCN) and seeds the defaulteditorialworkflow if absent (log-and-skip on validation failure, never boot-crash). -
Listener\WorkflowPointerMoveGuard— aBeforeRevisionPointerMoveEvent(L1) subscriber that closes the pointer-move bypass:rollback(),setCurrentRevision(), andsetPublishedRevision()move the base-row pointer WITHOUT adoSave()write, soWorkflowStateGuardalone could not see them. Validates the implied state change like a transition — same-state moves (e.g. promoting a forward draft, or rolling back to an earlier same-state revision) need the permission of any transition targeting that state; different-state moves need the real edge's own permission, no exceptions. -
Forward drafts (engine substrate, WP-2):
nodeopts into revisionable storage (revisionDefault: true, per-bundle opt-out viaNodeType::isNewRevision());TransitionServiceimplements the two-pointer status semantics — the base row'sstatusalways reflects the published-pointer revision's state, never the tip's. The engine supports a forward-draft entry edge (editing content back into adefault_revision: falsestate while the published pointer keeps serving the live revision) on any workflow that defines one; a laterpublishpromotes it (pointer moves,statusflips only after the pointer move commits — a guard denial never leavesstatusflipped with the pointer stuck). Raw saves never enact pointer moves — onlyTransitionService(or a direct, sanctioned repository call) moves the pointer. Forward drafts (a published → draft edge on the shippededitorialworkflow) are deferred: the WP-2 review found no read path is pointer-aware, so a forward draft's tip content is served byfind()-based readers while status/pointer reflect the published revision. Forward drafts return on true default-revision semantics (the base row keeps serving the published revision; drafts live only in revision rows).restore_to_published(archived → published) rounds out the shippededitorialworkflow alongsiderestore(archived → draft) so archived-content republishing has real edges — that round trip does not carry the live-content read-side risk above, since the entity is unpublished throughout. Backfilling legacy content'sworkflow_stateonto binding activation is a CLI step,workflows:backfill-state(seedocs/specs/operations-playbooks.mdPlaybook H) — deliberately binding-scoped, not framework-scoped, since the framework cannot know in advance which workflow a site will bind. Full mechanics:docs/specs/content-workflow.md"Forward-draft mechanics". -
Group (department) transition constraints, live (WP-3): a transition MAY carry
group_constraint: content_groups, fireable only by accounts that are members of a group the content itself belongs to — departments arewaaseyaa/groupsentities, membership and content-department assignment arewaaseyaa/relationshiprows, filtered to live (status = 1) rows only (Group\GroupConstraintChecker, backed byWaaseyaa\Groups\Membership\GroupMembershipService). Enforced at all four state-changing/state-revealing sites —TransitionService::transition()(immediately after the permission gate — permission wins when an account holds neither),getAvailableTransitions(),WorkflowStateGuard::guardUpdate(), and both branches ofWorkflowPointerMoveGuard— with the same fail-closed rule throughout: content with no recorded group can never satisfy a constraint, an unrecognised constraint kind denies rather than degrading to unconstrained, and a missing (null)GroupConstraintCheckerdenies every group-constrained transition rather than un-gating it (unconstrained transitions are unaffected by a missing checker).TransitionDeniedException::REASON_GROUP_CONSTRAINTis the new denial reason. Full contract:docs/specs/content-workflow.md"Group constraints (WP-3)".
API transition endpoints + admin SPA are WP-4.
Legacy machinery (superseded, removal tracked as WP-5 / #1920)
WP1 (landed): deleted the retired read-only dry-run/guards machinery — AuthoringRoleMatrix
(and its WorkflowServiceProvider singleton binding) plus the API-side WorkflowDryRunController
and WorkflowGuardsController. No compat shim; the endpoints are gone.
The classes below predate the CW-v1 engine and are not wired to any enforcement path —
EditorialWorkflowService::transitionNode() mutates fields in memory only; the caller must save
separately, with no guard proving the save is legitimate. They are kept only until a later WP-5
slice deletes them:
ContentModerator/ContentModerationState— the original state-machine driver, superseded byTransitionService.EditorialWorkflowService/EditorialTransitionAccessResolver— the ungated mutate-and-hope-you-saved path and its permission/role lookups, superseded byTransitionServiceWorkflowStateGuard.EditorialTransitionAccessResolveris retained only becauseEditorialWorkflowServicestill constructs one by default; it has no other live caller.
DomainValidationListener— never subscribed to any dispatcher; dead code kept alive in the dead-code gate only by its own unit test.
Live visibility contract: WorkflowVisibility exposes two deliberately
different questions. isCandidateStatePublic() resolves the selected
WorkflowState::$published declaration (the state id has no authority), while
isEntityServedPublic() / isEntityServedPublicForEntity() read the cast-aware
materialized status projection owned by the published pointer. The latter is
used by indexing, discovery, and relationship/SSR navigation, so a forward
draft remains served while its published pointer remains live.
WorkflowVisibilityFilter is the served-projection adapter for the legacy
relationship visibility interfaces. Full write-up: docs/specs/content-workflow.md
"Visibility (read side)".
Install
Ships as part of waaseyaa/framework — consumers who require the metapackage (or core / cms /
full) get it transitively. To depend on it directly:
composer require waaseyaa/workflows
WorkflowServiceProvider is auto-discovered via extra.waaseyaa.providers; it registers the
workflow config entity type, binds the engine services (WorkflowBindingResolver,
TransitionService, WorkflowStateGuard), wires the save-path guard, and seeds the default
editorial workflow. Requires PHP >= 8.5.
Key API
// Workflow.php — config entity (states + transitions) public function addState(WorkflowState $state): static public function getState(string $id): ?WorkflowState public function addTransition(WorkflowTransition $transition): static public function getValidTransitions(string $fromStateId): array // keyed by transition ID public function isTransitionAllowed(string $fromStateId, string $toStateId): bool public function getInitialState(): string public function permissionFor(WorkflowTransition $transition): string // WorkflowState.php — readonly value object public function __construct(string $id, string $label, int $weight = 0, array $metadata = [], bool $published = false, bool $defaultRevision = false) // WorkflowTransition.php — readonly value object (from: string[], to: string) public function __construct(string $id, string $label, array $from, string $to, int $weight = 0, string $permission = '') // Validation\WorkflowValidator.php public function validate(Workflow $workflow): array // list<string> violations; [] = valid // Binding\WorkflowBindingResolver.php public function resolve(string $entityTypeId, string $bundle): ?Workflow // null = unbound // Transition\TransitionService.php — the one enforcement door public function transition(EntityInterface $entity, string $transitionId, AccountInterface $account): TransitionResult public function getAvailableTransitions(EntityInterface $entity, AccountInterface $account): array // WorkflowTransition[] // Transition\TransitionDeniedException.php — reason is one of unbound/unknown_transition/illegal_edge/permission/group_constraint public readonly string $reason; // Listener\WorkflowStateGuard.php — PRE_SAVE subscriber public function onPreSave(EntityEvent $event): void
Usage
use Waaseyaa\Workflows\Transition\TransitionDeniedException; use Waaseyaa\Workflows\Transition\TransitionService; // $service is container-resolved (Waaseyaa\Workflows\Transition\TransitionService::class). try { $result = $service->transition($node, 'publish', $account); // $result->toState === 'published' } catch (TransitionDeniedException $e) { // $e->reason: 'unbound' | 'unknown_transition' | 'illegal_edge' | 'permission' | 'group_constraint' } // UIs render buttons from the read side only: foreach ($service->getAvailableTransitions($node, $account) as $transition) { // $transition->id, $transition->label }
Legacy usage (superseded — see above; do not build new code against this)
use Waaseyaa\Workflows\ContentModerationState; use Waaseyaa\Workflows\ContentModerator; use Waaseyaa\Workflows\EditorialWorkflowPreset; $moderator = new ContentModerator(); $moderator->addWorkflow(EditorialWorkflowPreset::create()); // id: 'editorial' $state = new ContentModerationState( entityTypeId: 'node', entityId: 1, workflowId: 'editorial', stateId: 'draft', ); $state = $moderator->transition($state, 'review'); // draft -> review $state->stateId; // 'review' // Disallowed transitions throw \InvalidArgumentException. $moderator->getAvailableTransitions($state); // WorkflowTransition[] valid from 'review'