Reusable Symfony bundle that exposes a Flowable
BPMN engine as Doctrine-less API Platform
pass-through resources, a per-request HTTP client and matching flowable:*
CLI commands.
The bundle owns no database tables: every resource reads from and writes to a
remote Flowable REST engine at request time. Connection details (base URL and
credentials) are resolved per request from an ApiConfiguration of type
flowable (provided by dmstr/api-configuration-bundle).
- PHP >= 8.4
- Symfony 7
- API Platform 4, at least 4.3.18 (earlier releases do not enforce
securityon MCP tools) - A reachable Flowable REST engine (
flowable-rest) ext-pcntl— only forflowable:external-worker:runas a long-lived process, where it enables the graceful shutdown described under External worker tasks. Not a hard dependency: without it the command runs, it just cannot react to SIGTERM.
composer require dmstr/flowable-bundleIf you do not use a Symfony Flex recipe that registers the bundle, add it to
config/bundles.php:
return [
// ...
Dmstr\Flowable\FlowableBundle::class => ['all' => true],
];The bundle is self-wiring: it ships its own service definitions
(FlowableBundle::loadExtension()), registers a dedicated flowable Monolog
channel and needs no entry in the application's services.yaml. API Platform
discovers the resource classes automatically from src/ApiResource.
Create an ApiConfiguration of type flowable. Its configJson is validated
against schema.json:
{
"base_url": "http://flowable-rest:8080/flowable-rest/service",
"auth_type": "basic",
"username": "rest-admin",
"password": "..."
}auth_type is either basic (requires username + password) or bearer
(requires token).
password and token are marked writeOnly in the schema, so
dmstr/api-configuration-bundle (^0.5) stores them encrypted at rest and
returns them masked as ******** on read; sending the mask back on update
keeps the stored value. This requires a configured encryption key
(CREDENTIALS_ENCRYPTION_KEY, see the api-configuration-bundle documentation).
FlowableClientLocator decrypts them only to build the client; if decryption
fails, the request fails with a SecretEncryptionException instead of sending
the ciphertext to the engine.
The config root is dmstr_flowable. Its only options switch the bundle's MCP tools (see MCP tools); all default to false, so an application that does not opt in serves no Flowable tool:
# config/packages/dmstr_flowable.yaml
dmstr_flowable:
mcp:
read: true # tools with readOnlyHint: true
write: false # every other tool
deploy: false # the deploy tools, in addition to writeA tool whose annotations declare readOnlyHint: true is kept only when mcp.read is on; every other tool counts as writing and is kept only when mcp.write is on. The deploy tools flowable_deploy_bundle and flowable_dmn_deploy need mcp.deploy on top of mcp.write: a deployed BPMN or DMN definition is code the engine runs (script, shell and HTTP tasks, expressions), so an agent that can deploy and start processes can execute code on the engine. A switched-off tool is removed from API Platform's resource metadata, so it is neither listed nor callable. API Platform caches that metadata, so run bin/console cache:clear after changing a switch.
All resources are served under /api/flowable/*:
| Resource | Notable operations |
|---|---|
FlowDeployment |
GET /deployments, GET /deployments/{id}, POST /deployments/upload, DELETE |
FlowProcessDefinition |
GET /process_definitions, POST /process_definitions/{id}/start, GET /process_definitions/{id}/input_schema |
FlowProcessInstance |
GET /process_instances, POST /process_instances, DELETE |
FlowTask |
GET /tasks, POST /tasks/{id}/complete, GET /tasks/{id}/input_schema |
FlowExecution |
GET /executions, POST /executions/{id}/trigger |
FlowHistoric* |
read-only history: activities, process instances, tasks, variables |
FlowDmnDeployment |
GET /dmn_deployments, GET /dmn_deployments/{id}, POST /dmn_deployments/upload, DELETE |
FlowDecision |
GET /decisions, GET /decisions/{id}, POST /decisions/execute |
FlowHistoricDecisionExecution |
read-only DMN evaluation history (audit, failed flag) |
FlowEventDeployment |
GET /event_deployments, GET /event_deployments/{id}, POST /event_deployments/upload, DELETE |
FlowEventDefinition |
read-only GET /event_definitions, GET /event_definitions/{id} |
FlowChannelDefinition |
read-only GET /channel_definitions, GET /channel_definitions/{id} |
FlowEventInstance |
POST /event_instances — send an event into the engine |
FlowExternalWorkerJob |
GET /external_worker_jobs, POST /external_worker_jobs/acquire, POST /external_worker_jobs/{id}/{complete,fail,bpmnError,unacquire} |
FlowJob |
read-only engine jobs: GET /jobs?kind=async|timer|suspended|deadletter|history, GET /jobs/{id}/stacktrace |
An <serviceTask flowable:type="external-worker" flowable:topic="…"> inverts the
direction of an integration: the engine calls nothing, it puts a job on a topic
and waits. That removes both problems a flowable:type="http" task has — no
inbound route from the engine to the application, and no credential in the
deployment artifact (the .bar usually lives in git; here the worker
authenticates).
Write a handler and you are done — the interface is autoconfigured, so no attribute and no service entry is needed:
final class InvoiceHandler implements ExternalWorkerHandlerInterface
{
public function topics(): array { return ['invoice-export']; }
public function handle(ExternalWorkerJob $job): ExternalWorkerOutcome
{
$id = $job->variables['invoiceId'] ?? null;
return ExternalWorkerOutcome::complete(['exportedAt' => date(DATE_ATOM)]);
// or: ExternalWorkerOutcome::bpmnError('INVOICE_REJECTED') → modelled error path
// or: ExternalWorkerOutcome::fail(retries: 2) → engine retry/backoff
// throwing is reported as fail() with the exception message
}
}Run the poll loop with flowable:external-worker:run (see CLI below). Two rules
the runner enforces, both deliberate: a handler that throws becomes a fail
(the engine owns backoff and dead-lettering — the worker adds no second retry
policy), and a topic without a handler is unacquired, never failed, because
failing would spend a retry over an incomplete deployment.
lockDuration and retryTimeout are ISO-8601 durations (PT10M), not
numbers. Note that a job which exhausted its retries disappears from
GET /external_worker_jobs and is then only visible as
GET /jobs?kind=deadletter — which is why the read-only FlowJob resource ships
with this feature rather than after it.
flowable-rest ships the DMN engine in the same container under the /dmn-api
prefix (the process engine lives under /service); both are reached through the
same flowable ApiConfiguration. The DMN engine keeps a separate
repository, so a .dmn packaged inside a process (.bar) deployment is not
registered as a decision — deploy decision tables via POST /dmn_deployments/upload.
POST /decisions/execute evaluates a decision by key ({ "decisionKey": "…", "inputVariables": {…}, "singleResult": false }) and returns the matching rule
outputs on the response's result.
Engine version: the DMN resources require a Flowable engine >= 8.0.0 (the DMN repository resource is named
decisions; earlier engines exposeddecision-tables).
flowable-rest ships a third engine repository under the /event-registry-api
prefix, again behind the same flowable ApiConfiguration. It stores event
definitions (.event — the payload contract and correlation keys of a
business event) and channel definitions (.channel — how events travel:
type is inbound/outbound, implementation the transport).
The operation worth having is POST /event_instances: it hands an event
straight to an inbound channel inside the engine, with no message broker in
between.
{
"eventDefinitionKey": "myEvent",
"channelDefinitionKey": "myInboundChannel",
"eventPayload": { "customerId": "42", "amount": 19.99 }
}Inbound event correlation (BPMN event sub-processes, receive events, boundary events) is therefore usable and verifiable without running Kafka or RabbitMQ. The outbound direction (engine → broker) leaves the engine and is a separate, broker-dependent concern that this bundle does not cover.
Both an event definition reference (eventDefinitionKey or
eventDefinitionId) and a channel definition reference (channelDefinitionKey
or channelDefinitionId) are required — the channel is what turns the
payload into a correlated event. The engine answers 204 No Content, so the
operation returns no body. eventPayload must be a JSON object.
No
.bar/.zipbundles here. Unlike the process and DMN repositories,POST /event_deployments/uploadaccepts only a single.eventor.channelfile (the engine's own API description claims otherwise, but it only ever tests those two suffixes). A deployment needing several resources is uploaded one file at a time.
The input_schema endpoints return the per-task / per-definition input
JSON-Schema, resolved at runtime from either an authored
<formKey>.schema.json deployment resource or by converting inline Flowable
form-data — via the pluggable flowable.task_form_source chain. Start forms
work exactly like task forms: put flowable:formKey="<key>" on the BPMN
startEvent and ship <key>.schema.json in the same deployment (.bar/
.zip); the resolved fields are wrapped in the businessKey / variables /
apiConfiguration envelope of the start operation.
A start-form schema may carry a top-level x-businessKey object to customise
the envelope's businessKey property — e.g. a Jedison x-watch/x-template
pair that composes the key live from the form variables:
{
"type": "object",
"x-businessKey": {
"title": "Business Key",
"x-watch": { "jahr": "#/variables/jahr", "revierId": "#/variables/revierId" },
"x-template": "foo-{{ jahr.value }}-{{ revierId.value }}"
},
"properties": { "…": {} }
}The object is merged over the default businessKey definition and stripped
from the variables schema — the composition rule ships with the process
deployment instead of being hard-coded in a UI. Details (semantics, watch
paths, Jedison rendering, IRI caveat):
docs/start-form-business-key.md.
A task-form field may name the process variable holding its data with
x-process-data-var, so a step that produced data can hand it to the next
step's form:
{
"landkreise": {
"type": "array",
"x-process-data-var": "landkreise"
}
}The variable's value — arrays and objects included, not just scalars like
{{ token }} — is moved into the field's default, which form renderers use as
the initial value; the keyword itself is stripped. Because completion writes the
field back to that same variable, reopening an unfinished task shows the last
saved state. A missing variable leaves the field without a default and keeps
the keyword visible for debugging. Details:
docs/task-form-prefill.md.
The bundle declares 14 MCP tools as API Platform McpTool entries on its resources. API Platform serves them when its MCP support is installed and enabled; the packages are suggested, not required:
symfony/mcp-bundleserves the tools;mcp/sdkis the MCP server runtime it runs on.
Nothing is exposed until the switches dmstr_flowable.mcp.read / mcp.write / mcp.deploy are turned on. Every tool requires ROLE_FLOWABLE_ADMIN, the read tools included: unlike the HTTP read operations, they are not open to ROLE_USER, because tool results go to a language model and the engine has no per-user access check on tasks, variables and history.
Warning: tool results contain engine data that users or external systems wrote (variables, task descriptions, exception messages). Treat it as untrusted input to the model, and do not give an agent that reads such data the write or deploy tools without a human confirming each call.
| Tool | Switch | Security | Tag (_meta de.dmstr/tag) |
Backing operation |
|---|---|---|---|---|
flowable_list_process_definitions |
read | ROLE_FLOWABLE_ADMIN |
Flowable |
GET /process_definitions (FlowProcessDefinitionProvider) |
flowable_list_tasks |
read | ROLE_FLOWABLE_ADMIN |
Flowable |
GET /tasks (FlowTaskProvider) |
flowable_get_task_form |
read | ROLE_FLOWABLE_ADMIN |
Flowable |
like GET /tasks/{id}/input_schema (FlowTaskFormProvider) |
flowable_get_process_status |
read | ROLE_FLOWABLE_ADMIN |
Flowable |
composite: process instance, open tasks, executions (FlowProcessStatusProvider) |
flowable_history_get |
read | ROLE_FLOWABLE_ADMIN |
Flowable/History |
composite: historic activities, historic variables, failed decision executions (FlowProcessHistoryProvider) |
flowable_system_list_deadletter_jobs |
read | ROLE_FLOWABLE_ADMIN |
Flowable/System |
GET /jobs?kind=deadletter, kind fixed (FlowJobProvider) |
flowable_start_process |
write | ROLE_FLOWABLE_ADMIN |
Flowable |
POST /process_instances, by key or id (ProcessInstanceCreateProcessor) |
flowable_complete_task |
write | ROLE_FLOWABLE_ADMIN |
Flowable |
POST /tasks/{id}/complete (TaskCompleteProcessor) |
flowable_dmn_evaluate |
write | ROLE_FLOWABLE_ADMIN |
Flowable/DMN |
POST /decisions/execute (DecisionExecuteProcessor) |
flowable_events_send |
write | ROLE_FLOWABLE_ADMIN |
Flowable/Events |
POST /event_instances (EventInstanceCreateProcessor) |
flowable_system_trigger_execution |
write | ROLE_FLOWABLE_ADMIN |
Flowable/System |
POST /executions/{id}/trigger (ExecutionTriggerProcessor) |
flowable_dmn_deploy |
write + deploy | ROLE_FLOWABLE_ADMIN |
Flowable/DMN |
POST /dmn_deployments/upload with an inline file (DmnDeploymentUploadProcessor) |
flowable_deploy_bundle |
write + deploy | ROLE_FLOWABLE_ADMIN |
Flowable |
several inline files as one .bar deployment (DeploymentBundleProcessor) |
flowable_system_execute_timer_job |
write | ROLE_FLOWABLE_ADMIN |
Flowable/System |
no HTTP operation; FlowableClientInterface::executeTimerJob() (TimerJobExecuteProcessor) |
Every tool carries annotations (readOnlyHint, destructiveHint, idempotentHint), its security expression and the de.dmstr/tag entry in _meta. Every tool that changes engine state in a way that cannot be undone (start, complete, trigger, send an event, deploy, execute a timer job) declares destructiveHint: true, so MCP clients ask before calling it; only flowable_dmn_evaluate, which adds no more than a history row, is a write tool with destructiveHint: false. Each description is one line that names the fields of the result, because some agent frameworks ignore structuredContent; the result is the serialised resource (or result object) in API Platform's MCP output format.
Notes on individual tools:
flowable_start_processstarts byprocessDefinitionKey(latest version) orprocessDefinitionId, so an agent needs no prior lookup of the definition id; it is backed byPOST /process_instances, not byPOST /process_definitions/{id}/start.flowable_dmn_evaluatechanges no engine state except a history row, but it is gated behindROLE_FLOWABLE_ADMINlike its HTTP operation, so it is declared a write tool (readOnlyHint: false,idempotentHint: true) and appears only withmcp.write.flowable_get_process_statusanswers only for running instances;flowable_history_getcovers ended ones. Both are composites: when one of their engine calls fails, the tool fails as a whole and never returns a partial result. Each list holds at most 200 rows; the*Totalfields tell whether it was cut.flowable_system_execute_timer_jobmoves the timer job to the executable jobs (POST management/timer-jobs/{id}with{"action": "move"}), where the async executor runs it at once. The remaining wait cannot be restored, hencedestructiveHint: true.flowable_deploy_bundlerefuses.dmnfiles: a process deployment does not register decision tables in the DMN engine, so they go toflowable_dmn_deploy. Building the archive needs the PHPzipextension.
All arguments are top-level. Operations with a JSON body take the body properties plus the URI variable (id), which is not part of the body; apiConfiguration selects the engine connection (full or partial UUID). List tools take page, itemsPerPage, sort, order and the filters of their HTTP collection; relation filters accept an id or an IRI. Variables are passed preferably as a map {"name": value}; the explicit list [{"name", "value", "type"}] is accepted too.
The advertised input schema of a tool is the operation input file next to its resource class (e.g. src/ApiResource/FlowTask/complete.input.json; tool-only files are named mcp<Verb>.input.json), with the URI variables added as required properties. Top-level allOf/anyOf alternatives ("one of these keys is required") are left out of the advertised schema because several MCP clients cannot represent them; the tool description names the rule, and the processor validates the full file.
Files are passed inline instead of as a multipart upload:
{
"name": "rates.dmn",
"content": "<?xml version=\"1.0\"?><definitions …>…</definitions>",
"deploymentName": "rates"
}name is the file name including its extension, content the file content as text; binary content (.bar, .zip) is sent base64-encoded with "contentEncoding": "base64". flowable_deploy_bundle takes a list of such files plus deploymentName:
{
"deploymentName": "order-process",
"files": [
{"name": "order.bpmn20.xml", "content": "<?xml …"},
{"name": "approve.schema.json", "content": "{\"type\": \"object\", …}"}
]
}Every REST operation has a matching command; command IDs mirror the resource URLs:
flowable:health
flowable:deployments flowable:deployments:upload
flowable:process-definitions
flowable:process-instances flowable:process-instances:create
flowable:tasks flowable:tasks:complete
flowable:executions flowable:executions:trigger
flowable:dmn:deployments flowable:dmn:deployments:upload
flowable:dmn:decisions flowable:dmn:rule:execute
flowable:events:deployments flowable:events:deployments:upload
flowable:events:definitions flowable:events:channels
flowable:events:instances:create
flowable:external-worker-jobs flowable:external-worker:run
flowable:external-worker:complete flowable:external-worker:fail
flowable:external-worker:bpmn-error flowable:external-worker:unacquire
flowable:jobs
flowable:external-worker:run is the poll loop: --topic (repeatable, empty =
every registered handler's topics), --worker-id, --lock-duration=PT10M,
--batch, --max-jobs, --time-limit, --once.
Graceful shutdown requires
ext-pcntl. With pcntl loaded, SIGTERM / SIGINT finish the current job, unacquire whatever is still locked and exit, so a container restart leaves no jobs locked. Without pcntl the command still runs (getSubscribedSignals()returns an empty list, so Symfony does not refuse to start it) — but nothing handles the signal: on SIGTERM the acquired jobs stay locked until theirlockDurationexpires, and the engine hands them out again only after that. Install pcntl before running this as a long-lived worker process, and keep--lock-durationshort enough that a lost lock is not painful. Verified 2026-07-29 on a php:8.4 image without pcntl.
Reading is open to any authenticated user. Starting, triggering and completing
(all write operations) require ROLE_FLOWABLE_ADMIN. The MCP tools all require
ROLE_FLOWABLE_ADMIN, see MCP tools. The acting user is taken
from the authenticated identity (JWT sub) and propagated to Flowable — see
docs/tenant-and-user.md.
docs/walkthrough-vacation-request.md— end-to-end walkthrough of a human-task process.docs/tenant-and-user.md— tenant and acting-user model.docs/start-form-business-key.md— thex-businessKeystart-form extension.docs/task-form-prefill.md— thex-process-data-vartask-form extension.
The PHPUnit suite (tests/, configuration phpunit.dist.xml) runs without an engine, a database or a kernel: the Flowable client is a test double, and the MCP tool metadata is read from the resource attributes with API Platform's attribute metadata factory.
composer install
vendor/bin/phpunitMIT © diemeisterei GmbH