Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Camunda Laravel Package

laravolt/camunda

Laravel wrapper for the Camunda BPMN workflow engine. Start and manage process instances, query and complete user tasks, handle external tasks and incidents, and call the Camunda REST API through a fluent, config-driven client integrated with Laravel's service container.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and publish.

composer require laravolt/camunda
php artisan vendor:publish --tag=camunda-config   # tag may differ; fall back to --provider

The service provider and Camunda facade are auto-discovered. Your first stop should be config/camunda.php — everything you care about on day one lives there: the Camunda REST base URL (/engine-rest), authentication (basic auth vs. none), and the default lock duration / poll limits for external tasks. Point it at a local engine before touching application code:

docker run -d --name camunda -p 8080:8080 camunda/camunda-bpm-platform:run-latest

2. Read the facade and the external-task class first. This is a thin wrapper, not a framework — the whole package is a handful of classes. Open the facade's docblock and the external-task handler class and you will know the entire API surface in ten minutes. Do that before searching for docs, because community Camunda clients are chronically under-documented and version drift means blog posts won't match.

3. Start with the read side, not the write side. Deploy one trivial BPMN diagram (a Start → Service Task with an external topic → End) and verify connectivity with an inspection call — list process definitions, or fetch-and-lock a batch of tasks with an empty handler. Confirming you can see the engine from Laravel removes half the ambiguity from later failures.

4. Your first real use case: a single external task topic where the Laravel side does something narrow and idempotent — send a notification, write a log row, fetch a document. Avoid starting with anything that moves money or writes to a ledger. The external-task lifecycle (fetch → lock → work → complete/fail) is the mental model you are really learning; the BPMN shape matters less at this stage.

5. Wire up a worker process. The package handles fetch/lock/complete; you supply the process that keeps it running. An Artisan command with a while(true) loop (or --once driven by Supervisor) is the standard shape. Register it on a dedicated queue, not the default application queue.


Implementation Patterns

The external-task worker loop

This is the pattern you'll write on day one and reuse everywhere. Shape of the code (verify method names against the facade docblock; they're stable within a major):

class ProcessOnboardingTask extends Command
{
    protected $signature = 'camunda:worker:onboarding {--once}';

    public function handle(Camunda $camunda): int
    {
        do {
            $tasks = $camunda->externalTask()
                ->topic('onboarding-verify-documents')
                ->lock(seconds: 120, maxTasks: 10);

            foreach ($tasks as $task) {
                $this->runIsolated($task);   // see failure handling below
            }

            $this->option('once') && sleep(2);
        } while (! $this->option('once'));

        return self::SUCCESS;
    }
}

Supervisor runs php artisan camunda:worker:onboarding with numprocs for horizontal scaling; --once mode is what you use in tests and cron-driven deployments.

Correlate the engine to your domain model

Store process_instance_id (and business_key when you start the process) on the Eloquent model that the process is about. Set businessKey at start time to your model's primary key — it makes the Camunda Cockpit usable by non-engineers and gives you a one-line lookup:

$instance = Camunda::process()->start('onboarding', [
    'businessKey' => "order-{$order->id}",
    'variables'   => ['orderId' => ['value' => $order->id, 'type' => 'Long']],
]);
$order->update(['camunda_instance_id' => $instance->id]);

Handler dispatch, not handler spaghetti

Keep the BPMN worker thin. The pattern that survives contact with production is: external task → look up a topic-handler class → call an application service → complete or fail. Business logic lives in App\Services, not in package callbacks. This is also your exit ramp: if you ever swap the wrapper for the official client, only the top layer moves.

$task = $camunda->externalTask()->fetchOne('onboarding-verify-documents');

$topicHandlers = [
    'onboarding-verify-documents' => VerifyDocumentsHandler::class,
    'onboarding-send-welcome'     => SendWelcomeEmailHandler::class,
];

app($topicHandlers[$task->topicName])->handle($task->variables);

$camunda->externalTask()->complete($task->id);

Separate technical failure from BPMN error

The distinction is the whole point of the external-task pattern and the thing most teams get wrong:

  • Technical failure (timeout, HTTP 500, database down) → fail() with retries and a backoff. Camunda will retry per the BPMN's retry cycle.
  • BPMN error (business rule violation — invalid document, customer not eligible) → raise the BPMN error with an error code the diagram routes to an error boundary event. This is expected flow, not a bug.

Do not conflate them. If you fail() on a business rejection you'll fill the incident queue with things a human can't resolve by retrying; if you complete a task that really failed, the process marches forward on bad data.

try {
    $this->verifyDocuments($task->variables);
    $camunda->externalTask()->complete($task->id);
} catch (DomainException $e) {
    $camunda->externalTask()->completeWithError($task->id, 'DOCUMENT_INVALID', $e->getMessage());
} catch (Throwable $e) {
    report($e);
    $camunda->externalTask()->fail($task->id, retries: 3, retryTimeout: 60);
}

Idempotency as a habit, not an afterthought

External tasks are at-least-once. Every handler with side effects gets an idempotency guard keyed on the external task ID (or on your own business key for the side effect itself):

DB::transaction(function () use ($task) {
    if (LedgerEntry::where('external_task_id', $task->id)->exists()) {
        return; // already done in a previous lock
    }
    LedgerEntry::create([...]);
});

Log the external task ID on every outbound side-effecting call (email provider, payment gateway, webhook). When a duplicate fires in production at 2 a.m., that log line is how you reconcile.

Start processes from domain events, not controllers

The durable pattern is: order is placed → domain event fires → listener starts the BPMN instance. Controllers stay thin and process start survives retries via the queue:

Event::listen(OrderPlaced::class, function (OrderPlaced $event) {
    Camunda::process()->start('order-fulfilment', [
        'businessKey' => "order-{$event->order->id}",
        'variables'   => ['orderId' => ['value' => $event->order->id, 'type' => 'Long']],
    ]);
});

Testing against a real engine

Mocking the Camunda client is a trap — it lets contract drift through. Stand up Camunda in your CI via a Docker service, deploy your test BPMN, and run one happy-path worker against it. That single smoke test is your gate for every package bump.


Gotchas and Tips

Lock duration is the single most consequential knob. Set it above your p99 handler latency plus headroom. If the lock expires mid-handler and another worker picks up the task, you get a duplicate execution with no error to warn you. Tune by measurement, not by hope.

The failure mode is not "it crashed" — it's "it succeeded twice." Lock expiry and crash-after-side-effect both surface silently as duplicated emails, duplicated charges. Idempotency guards and correlation logging are your only reliable defences. Neither is a package feature.

Camunda 7 vs. Camunda 8. This package targets the Camunda 7 REST external-task API (/engine-rest). Camunda 8 / Zeebe uses a different job-worker protocol (gRPC). If you're evaluating this because your organisation is moving to Camunda 8, stop — the wrapper is a Camunda 7 bridge, not a Camunda 8 client. Confirm this before any architecture deck says "Camunda."

config:cache will hide a newly added config key. If a release adds a key you never published, the cached config will not contain it and you'll get null behaviour that looks like a bug in your code. After any package bump, php artisan config:clear && vendor:publish --force — but never --force in production without diffing first, because it will overwrite your production config.

2.7.0 has no release notes. For an assessment this matters: treat the bump as unverified. Read the 2.6.0...2.7.0 diff for config key renames, minimum PHP/Laravel bumps, and changed default timeouts (lock duration, retry count, maxTasks). A silent default change in lock duration is exactly the class of change that alters runtime behaviour without failing your test suite.

Variable typing is JSON-serialised and Camunda is strict. Scalars pass through, but objects, dates, and nulls need explicit type in the variables map. A PHP DateTime sent without a type becomes a string the diagram can't compare. A null with no type becomes a NullType that some expressions choke on. When a BPMN gateway misbehaves, suspect the variable type before the expression.

One worker pool, not shared with app queues. If BPMN tasks share a queue with user-facing jobs, a slow process starves the site. Separate Supervisor pools give you independent scaling and an on-call signal that's actually about workflow health.

Polling throttles at the engine, not at PHP. fetchAndLock volume is the natural rate limiter. A 200-worker fleet polling an undersized engine will take the engine down — and Camunda's bottlenecks show up in the engine's metrics (history table growth, Job Executor throughput), not yours. Aggressive history TTL in non-production, or your staging DB fills quietly.

Backoff with jitter on engine unavailability. If Camunda is down or the network partitions, a naive while(true) loop becomes a retry storm the moment it recovers. Add exponential backoff and a simple circuit breaker before this ever matters; retrofitting it during an incident is painful.

Multi-tenancy lives in tenantId on fetch, not in the config. If you run a multi-tenant engine, every fetch must pass a tenant ID or you'll pull tasks belonging to other tenants. This is the classic gap in community clients — verify the signature supports it before assuming isolation.

Wrap the facade behind your own interface from week one. Inject App\Contracts\Workflow rather than the Camunda facade directly. The exit cost when upstream goes stale — or when Camunda 7's end-of-life catches up with you — is the difference between changing one binding and touching every controller, listener, and job. It costs you an hour now and saves you a fortnight later.

Read the source occasionally. The library is small enough to audit and the documentation is thin. When something behaves oddly around retries, error codes, or response mapping, twenty minutes in vendor/laravolt/camunda/src beats an hour of Stack Overflow archaeology — because there likely isn't a Stack Overflow answer.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core