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

Workflow Helper Bundle Laravel Package

survos/workflow-helper-bundle

A Symfony bundle that adds convenience helpers around the Symfony Workflow component, simplifying workflow configuration, transitions, and guards so you can wire state machines into your application with less boilerplate.

View on GitHub
Deep Wiki
Context7

Getting Started

First, the hard truth: this is a Symfony bundle, not a Laravel package. The composer metadata will resolve in a Laravel project, but Survos\WorkflowHelperBundle extends Symfony\Component\HttpKernel\Bundle\Bundle and its services are wired through a DependencyInjection\Extension plus Twig tags. Laravel's container never registers Symfony bundles, so composer require survos/workflow-helper-bundle gets you autoloaded classes and nothing else — no routes, no service, no helper. The [READ ONLY] prefix in the description confirms this repo is a split mirror of the survos monorepo; issues and PRs live there, not here. With 4 stars and 0 dependents, treat it as reference code you read, not a dependency you install.

So the realistic getting-started path is:

composer require symfony/workflow   # framework-agnostic, works fine in Laravel
composer require --dev survos/workflow-helper-bundle  # optional: read the source, don't boot it

Where to look first, in this order:

  1. src/DependencyInjection/Configuration.php — the config tree tells you exactly what the bundle expects: workflow names, entity/support classes, marking store, route prefixes. This is the closest thing to documentation the package has.
  2. src/DependencyInjection/WorkflowHelperExtension.php — how services are built and which Twig namespaces/tags get registered. You'll translate this into a Laravel service provider.
  3. Whatever class exposes the "helper" (look for WorkflowHelper, WorkflowExtension, or a *Runtime Twig class) — this is the actual value: a thin facade over Symfony\Component\Workflow\Registry that resolves a subject to its workflow and returns enabled transitions with metadata attached.
  4. The controller(s) and their routes — this is the "admin/developer interface" the description advertises, i.e. a generic POST endpoint that applies a transition and a page rendering available transitions as buttons.
  5. vendor/.../Resources/ (or templates/) — the Twig macros you'd reimplement in Blade.

Your first use case should be the canonical one: an Order with a state column, a small state machine (pending → paid → shipped → cancelled), transition buttons in a Blade view, and a POST route that applies them. That single vertical slice exercises everything the bundle does and is ~80 lines of your own code:

// config/workflows.php
return [
    'order' => [
        'type' => 'state_machine',
        'marking_store' => ['type' => 'method', 'property' => 'state'],
        'supports' => [App\Models\Order::class],
        'places' => ['pending', 'paid', 'shipped', 'cancelled'],
        'transitions' => [
            'pay'       => ['from' => 'pending', 'to' => 'paid'],
            'ship'      => ['from' => 'paid',    'to' => 'shipped'],
            'cancel'    => ['from' => ['pending', 'paid'], 'to' => 'cancelled'],
        ],
    ],
];

Once that works, port the bundle's admin screen (a table of defined workflows, places, transitions, and which subjects sit in each place). That is genuinely useful during development and is the one thing the bundle gives you that the base component does not.

Implementation Patterns

Pattern 1 — wrap the Registry in a single injected service, not facades. Bind Symfony\Component\Workflow\Registry as a singleton in a service provider, build definitions from config('workflows') at boot, and expose one app-level class:

final class WorkflowManager
{
    public function __construct(private Registry $registry) {}

    public function for(Model $subject): Workflow
    {
        return $this->registry->get($subject);
    }

    public function can(Model $subject, string $transition): bool
    {
        return $this->for($subject)->can($subject, $transition);
    }

    public function apply(Model $subject, string $transition): void
    {
        DB::transaction(function () use ($subject, $transition) {
            $this->for($subject)->apply($subject, $transition);
            $subject->save();
        });
    }
}

Note the save() inside the transaction — apply() mutates the marking on the object but does not persist it. This is the single most common surprise for people coming from Laravel state-machine packages, which usually save for you.

Pattern 2 — one generic transition endpoint. Mirror the bundle's controller shape rather than writing a route per transition. It keeps you from inventing POST /orders/{id}/pay for every edge in the graph, and it prevents users from POSTing a transition that doesn't apply to that subject.

Route::post('/{model}/{id}/transition/{transition}', TransitionController::class)
    ->middleware(['auth', 'can:update,model'])
    ->whereIn('model', ['orders', 'invoices'])
    ->name('workflow.transition');

In the controller: resolve the subject, abort_unless($manager->can($subject, $transition), 409), apply, redirect back with a flash. Return 409, not 403 — the transition being disallowed is a state conflict, not a permissions failure, and it makes the distinction visible in logs.

Pattern 3 — a Blade component instead of a Twig macro. The bundle's helper almost certainly exposes something like workflow_transitions(subject) returning enabled transitions with their metadata so a template can render buttons/forms. Reimplement as <x-workflow-actions :subject="$order" /> that loops enabled transitions, renders one <form> per transition with @csrf and the transition name in a hidden field, and reads label/color/confirm from transition metadata. Metadata is where you put UI concerns so the graph definition stays declarative.

Pattern 4 — reproduce the admin/debug page as a dev-only route. The bundle's headline feature is an inspect-everything screen. In Laravel, gate it behind app()->environment('local') or a viewWorkflow gate, and render: all workflows → places → transitions → counts per place. Add an artisan command for the terminal version:

// php artisan workflow:dump order
foreach ($definition->getTransitions() as $t) { /* name, froms, tos, metadata */ }

This pays for itself the first time someone asks "why can't I ship this order?"

Pattern 5 — guards are your authorization layer, listeners are your side effects. The component dispatches workflow.<name>.guard.<transition> (vetoable) and workflow.<name>.transition. Map them to Laravel events and listeners; do not put business rules in the controller and do not put emails in a guard. Guards should be pure predicate checks ($order->total > 0); anything that writes or sends belongs in a listener on the transition event, and should be queued.

Pattern 6 — keep the graph in config, the behavior in code. Workflow definitions are data: they belong in config/workflows.php (or a DB table if you want ops to edit them). Guards/listeners are code. The moment you start branching on $order->state === 'paid' inside a controller, you've rebuilt the thing you just installed.

Integration tips

  • Set the model's $casts for state to a PHP enum, but verify the method marking store calls setState() with a string and that your enum has a from() path — otherwise you get a ValueError deep inside the store.
  • Define public string $workflow_name = 'order'; (or a workflow() method) on each model so the Registry resolves unambiguously when a class is supported by more than one definition.
  • Cache the built Definition objects per workflow name; rebuilding them on every request is measurable once you're past a handful of states.
  • If you're porting the bundle into a real Symfony app instead, the translation is mechanical: register in config/bundles.php, import the bundle's routes, and keep your definitions in config/packages/workflow.yaml instead of a PHP array.

Gotchas and Tips

The [READ ONLY] marker is a warning, not a detail. This repository is generated from the survos monorepo. Any issue you file here is invisible, and the code you're reading may be a snapshot behind main. Before you copy a pattern, diff against the monorepo. The 2025-07-22 release date and near-zero adoption mean there is no ecosystem pressure to keep BC — pin an exact version if you do install it, and expect minor-version breakage.

can() and getEnabledTransitions() can disagree. can() consults guards; getEnabledTransitions() also consults guards but returns objects whose metadata you may be rendering. If a guard depends on request context (current user, a flag set later in the request), buttons rendered early can be stale by the time the POST arrives. Always re-check can() server-side; never trust the rendered set.

state_machine vs workflow changes your invariants. A state machine allows exactly one place to be marked; a workflow allows many simultaneously. The bundle's config exposes this as type, and the default in some versions is workflow. If you don't set it explicitly you can end up with a subject in two states and a UI that only renders one of them. State machines are what you want 95% of the time.

Metadata is a flat map of scalars. Symfony's definition loader will throw if you nest arrays or objects in transition metadata. ['label' => 'Mark paid', 'color' => 'green'] is fine; ['form' => ['fields' => [...]]] is not. Serialize structured payloads to a JSON string or key them into separate scalar entries.

Transition names leak into event names and routes. A transition called mark.paid produces the event workflow.order.guard.mark.paid and an ambiguous route segment. Stick to [a-z_]+. Renaming a transition later means renaming its listeners and its database-stored audit rows — treat transition names as schema.

Concurrency is unhandled. The component reads the marking, checks guards, writes the marking, with no locking. Two simultaneous "ship" requests can both pass the guard and both fire the listener (double email, double webhook). Wrap apply() in DB::transaction with lockForUpdate(), or add a version column and assert it in a guard. Do this on day one; retrofitting is painful once audit rows exist.

A null or missing marker property throws. If state is nullable and unset, MethodMarkingStore fails unhelpfully on the first can(). Give the column a default of the initial place and a NOT NULL constraint. Same class of bug: a marking store configured as property: state when your model actually exposes getStatus()/setStatus() — the error surfaces as an undefined-property warning from deep inside the component, not as a clear config error.

Debugging: turn on the listeners. workflow.<name>.guard.<transition> logs are the fastest route to "why was this vetoed." In Symfony you get the profiler's workflow panel for free; in Laravel, add a temporary listener on workflow.*.guard.* that logs the subject id, transition, and result. Also Log::debug the full enabled-transition set on 409 responses — that turns a support ticket into a one-line diagnosis.

Config quirks to check before you trust the bundle's defaults. Whether supports is inferred or must be declared, whether the route prefix is configurable, and whether the bundle registers routes at all (many helper bundles ship the service and expect you to write the controller). Read Configuration.php before assuming a feature exists.

Extension points, in order of stability: the Registry itself (always safe — it's the component's own API), the workflow events (safe, it's documented component behavior), transition metadata (safe, it's just data), the bundle's service class (unstable, read-only upstream), and the bundle's Twig templates (most unstable — reimplement rather than override). Anything you copy from this repo should be copied into your own app/ namespace with a comment pointing at the source commit, so a future maintainer knows why the code looks like Symfony.

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.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
cgv/localized-resource