integrated/workflow-bundle
A workflow bundle for PHP applications that lets you define state machines, transitions, and guards in configuration, then move records through them with a clean API. Includes event hooks, validation, and audit-friendly history.
Reconnaissance before installation (15 minutes). This is a Bitbucket-hosted package with no Packagist presence, zero stars and zero dependents — treat it as a private bundle you're evaluating, not a community library. Clone it and answer three questions:
git clone [email protected]:eactive/integrated-workflow-bundle.git
cat composer.json # require constraints, type, autoload, branch-alias
ls src/ # Bundle class? DependencyInjection/? Resources/config/?
git tag && git log --oneline -20
-bundle suffix almost certainly means it registers via a *Bundle class in a Symfony kernel — there will be no WorkflowServiceProvider waiting for Laravel's package discovery. Check for DependencyInjection/Extension.symfony/workflow? If so, the portability win is large: places/transitions/guards/marking stores are a well-understood model and it's mainly YAML→service wiring you'd be replacing.require pin? A symfony/*: ^3.4|^4.0 constraint or a hard doctrine/* dependency is a red flag on Laravel 10/11 and PHP 8.2+.Install via VCS (no Packagist entry):
"repositories": [
{ "type": "vcs", "url": "[email protected]:eactive/integrated-workflow-bundle.git" }
],
"require": {
"integrated/workflow-bundle": "dev-main#<commit-sha>"
}
Pin to an explicit commit or tag from day one — never let a floating dev-main reach production. For CI, provision SSH agent forwarding or a COMPOSER_AUTH Bitbucket access token; a private VCS repo that fails to clone breaks every pipeline silently.
Decide placement before writing code. Three options, in order of preference:
App\Workflow\WorkflowService interface so application code never imports vendor classes.First use case: one linear lifecycle on one Eloquent model. Pick something boring and high-value — Order: draft → submitted → approved → fulfilled, or Invoice: pending → paid → void. Get one can(), one apply(), and one persisted state column working end to end before touching guards, listeners, or history.
Wrap the engine behind a narrow application interface. This is the single highest-leverage decision you'll make, because it caps blast radius if the bundle goes quiet:
interface WorkflowService
{
public function can(Model $subject, string $transition, string $workflow = 'default'): bool;
public function apply(Model $subject, string $transition, string $workflow = 'default'): void;
public function enabledTransitions(Model $subject, string $workflow = 'default'): array;
}
Controllers, jobs, and form requests depend on this interface — never on vendor classes. When the upstream dies, you swap one binding.
Persist marking on the model. Symfony's marking store keeps state as an array of place names (to support parallel places). Map it to a JSON column, not a varchar, unless you're certain you'll only ever be in one place at a time:
// migration
$table->json('workflow_marking')->nullable();
$table->string('workflow_name')->default('default');
// trait
public function marking(): array { return $this->workflow_marking ?? []; }
public function inPlace(string $place): bool { return in_array($place, $this->marking(), true); }
Guards belong in Laravel policies, not bundle config. If the bundle lets you attach guards declaratively, have each guard delegate to a policy method or a dedicated App\Workflow\Guards\* class. That keeps business rules testable with Laravel's normal tooling and prevents config-driven guard logic from drifting outside code review:
// instead of closure logic in YAML
public function canApprove(Order $order): bool
{
return $order->total < 10_000 && $order->customer->isVerified();
}
Bridge the event dispatcher, don't replace it. Symfony dispatches workflow.<name>.transition, .enter, .leave, and .guard events. If you're running it in-process, register a single subscriber that re-emits Laravel events:
Event::listen('workflow.*.transition', function ($eventName, array $payload) {
[$name, $transition] = /* parse */;
event(new WorkflowTransitioned($payload['subject'], $transition));
});
Then normal Laravel listeners, queued jobs, and notification mailables attach to WorkflowTransitioned. Keep side effects (email, webhooks, search indexing) in DB::afterCommit() listeners so they fire only after the transition persists.
Atomic transitions are your responsibility. The engine enforces transition validity, not database integrity. Wrap the call site:
DB::transaction(function () use ($order) {
$locked = Order::lockForUpdate()->findOrFail($order->id);
$this->workflow->apply($locked, 'approve');
$locked->save();
});
Audit by subscription, not by scattering writes. A single RecordWorkflowTransition listener writing to an append-only workflow_transitions table (subject type/id, from_place, to_place, transition, actor_id, payload JSON, created_at) gives you history for free and keeps the call sites clean.
Registry pattern for multi-workflow apps. When you have more than 2–3 workflows, a WorkflowRegistry keyed by name (with definitions declared in PHP or config/workflows.php) beats sprinkling service names through controllers. Resolve by $order->workflow_name so the model knows which graph applies to it.
Testing. Unit-test workflow definitions as data (assert a given transition is permitted from a given place), and feature-test the HTTP endpoints that trigger transitions. A fast test helper pays off immediately:
$this->order->setMarking(['draft']);
$this->post("/orders/{$this->order->id}/submit")->assertRedirect();
$this->assertDatabaseHas('workflow_transitions', ['transition' => 'submit']);
"Bundle" ≠ "package." There is no package auto-discovery, no ServiceProvider, no facade. If it ships a Symfony Bundle class and DependencyInjection/Extension, you cannot composer require your way to a working install in Laravel — expect to write the adapter, or run a sidecar.
Silent no-op transitions. Symfony's workflow engine typically returns false from apply() on an invalid transition rather than throwing. Wrap it so invalid transitions raise a domain exception (InvalidTransitionException) — otherwise a bug in your UI logic becomes a missing audit record and no one notices until a customer complains.
Multi-place state will break naive code. Symfony's marking store allows parallel places (['draft', 'legal_review']). If you write $order->status === 'approved' anywhere, it will bite you later. Always compare via inPlace() or the engine's can().
Symfony DI expects service definitions, not closures. Guards and validators are wired through the container, so passing Laravel container-resolved services into Symfony definitions requires a compiler pass or a small bridge. Resolve guards through Laravel's container inside a thin adapter class instead of trying to teach Symfony DI about Laravel.
Transaction and event ordering. If the engine dispatches transition events before you commit, listeners see the new marking but the row isn't durable. If it dispatches after, listeners may run outside your transaction. Either way, use DB::afterCommit() for anything with side effects, and never allow queued listeners to depend on in-flight state.
Concurrency. Two admins clicking "approve" simultaneously can produce duplicate transitions. Defend with lockForUpdate() (or a version column and a unique index on (subject_type, subject_id, transition, idempotency_key)). The engine will happily let both through.
Private VCS auth in CI. A missing SSH key or expired Bitbucket app password fails the build with a confusing Composer error. Store credentials as pipeline secrets, and mirror the package to an internal Composer registry (Satis/Private Packagist) so a Bitbucket outage doesn't block deploys.
Version drift. With no changelog or release cadence, lock to a commit SHA and add a smoke test that boots the container and executes one trivial transition. That single test catches 90% of incompatible upgrades before they reach staging.
Debugging "why can't I transition?" The engine tells you false; it doesn't tell you which guard rejected. Build a small helper that iterates guards and returns the first failure reason, and log it at debug level. Without it, every support ticket becomes a source dive.
Audit table growth. Transition history grows linearly with user activity — budget a retention or archival policy before it becomes a slow-query problem. It is a common latent cost in workflow engines.
Extension points worth knowing. Custom marking stores (e.g. Eloquent-backed), guard/validator service classes, and event subscribers are the three places you'll actually modify. Everything else should be reached through your WorkflowService interface.
If the bundle is Symfony-kernel-bound, stop. Do not attempt to boot a Symfony kernel inside a Laravel process. Either run a sidecar or reject the dependency and use symfony/workflow standalone (it's genuinely framework-agnostic) or a small internal state machine. Both are cheaper than an adapter you'll maintain indefinitely.
How can I help you explore Laravel packages today?