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 Bundle Laravel Package

sprintf/workflow-bundle

A Symfony bundle that integrates the Workflow component, helping you configure state machines and workflows with places, transitions, guards, and events through simple configuration.

View on GitHub
Deep Wiki
Context7

Getting Started

This is a thin, low-adoption wrapper (1 star, 0 dependents) around Symfony's own symfony/workflow component. Treat Symfony's workflow docs as the primary reference and the bundle as a configuration convenience — everything you learn about WorkflowInterface, marking stores, and events transfers directly.

Install and register

composer require sprintf/workflow-bundle

Check whether a Flex recipe was applied. With 1 star and 0 dependents, assume it was not:

// config/bundles.php
return [
    // ...
    Sprintf\WorkflowBundle\SprintfWorkflowBundle::class => ['all' => true],
];

Confirm the actual class name and any config alias by reading vendor/sprintf/workflow-bundle/src/ and DependencyInjection/Configuration.php before writing config — the root key is usually the vendor name, e.g. sprintf_workflow, but verify it.

Where to look first, in order

  1. vendor/sprintf/workflow-bundle/README.md — often the only real documentation for packages this small.
  2. DependencyInjection/Configuration.php — the authoritative schema; it tells you every option the bundle actually supports.
  3. DependencyInjection/*Extension.php — shows what services and parameters get registered, and whether it delegates to Symfony's FrameworkExtension workflow config or builds its own.
  4. composer.json — check the symfony/* version constraints. A small bundle lagging on symfony/framework-bundle will break on the next major.
  5. bin/console debug:config sprintf_workflow and bin/console debug:container --tag=workflow once installed.

First use case: an order status field

Start with a single state machine on one entity, because that is the pattern you will reach for 90% of the time.

enum OrderStatus: string
{
    case Draft = 'draft';
    case Confirmed = 'confirmed';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';
}
# config/packages/workflow.yaml  (or the bundle's root key if it wraps this)
framework:
    workflows:
        order:
            type: state_machine
            audit_trail: { enabled: true }
            marking_store:
                type: method
                property: status
            supports: [App\Entity\Order]
            initial_marking: draft
            places:
                - draft
                - confirmed
                - shipped
                - cancelled
            transitions:
                confirm:
                    from: draft
                    to: confirmed
                ship:
                    from: confirmed
                    to: shipped
                cancel:
                    from: [draft, confirmed]
                    to: cancelled

Inject the specific workflow by its service id and drive it from a controller or command:

public function __construct(
    private readonly WorkflowInterface $orderStateMachine, // autowire by name: $orderStateMachine -> state_machine.order
) {}

public function confirm(Order $order): void
{
    if (!$this->orderStateMachine->can($order, 'confirm')) {
        throw new \DomainException('Order cannot be confirmed.');
    }

    $this->orderStateMachine->apply($order, 'confirm');
}

That is a complete, working vertical slice. Get this running and dumped to a graph (bin/console workflow:dump order | dot -Tpng > order.png) before touching anything bundle-specific.

Implementation Patterns

State machine vs. workflow. Choose state_machine when the entity holds exactly one place at a time (a status column). Choose workflow when it can hold several simultaneously (content moderation plus publication, parallel approval steps). Switching later means a marking-store migration, so decide up front by asking "can two of these be true at once?"

Marking store selection. method with a typed property is the modern default and plays well with PHP enums. Use single_state only for an untyped string column, and multiple_state (an array property) only for true workflow types. A mismatch produces confusing "marking is not valid" errors rather than a clear type error.

Guard logic: expressions for trivial, listeners for everything else.

confirm:
    from: draft
    to: confirmed
    guard: "is_granted('ORDER_CONFIRM', subject)"

For anything involving repositories or multiple services, use a listener — it is testable and loggable:

#[AsEventListener(event: 'workflow.order.guard.confirm')]
final class ConfirmGuard
{
    public function __invoke(GuardEvent $event): void
    {
        $order = $event->getSubject();
        if (!$this->inventory->hasStock($order)) {
            $event->setBlocked(true, 'Out of stock');
        }
    }
}

The setBlocked(true, $reason) form is worth adopting everywhere: it is the only place the reason is retrievable later, and it will save you hours when a transition silently refuses to fire.

Audit trail as your integration surface. Enable audit_trail and persist it, or listen to workflow.order.transition and write an order_status_history row. This is the single highest-value addition to any workflow integration — it turns "why is this order stuck?" into a query.

#[AsEventListener(event: 'workflow.order.transition')]
final class RecordTransition
{
    public function __construct(private EntityManagerInterface $em) {}

    public function __invoke(TransitionEvent $event): void
    {
        $this->em->persist(new OrderStatusHistory(
            order: $event->getSubject(),
            transition: $event->getTransition()->getName(),
            from: $event->getMarking()->getPlaces(),
            at: new \DateTimeImmutable(),
        ));
    }
}

Beware: this listener fires during apply(), before your own flush. If the surrounding transaction rolls back, the history vanishes with it — usually what you want, occasionally not.

Encapsulate transitions on the entity, not in controllers. Let the entity expose intention-revealing methods that call the injected workflow, or better, keep the workflow in a domain service per aggregate. Controllers that call apply() directly scatter the state rules and make can() checks easy to forget.

Config in PHP for anything generated. If transitions map to permission checks or are driven by a table, define workflows in a compiler pass or a ContainerConfigurator extension rather than YAML. The bundle's added value, if any, is usually here — check whether it exposes a builder API before hand-rolling one.

Test against the workflow, not the entity. Use the workflow service directly in unit tests with a plain object; no kernel boot required if you construct Symfony\Component\Workflow\Workflow manually with a MethodMarkingStore. Reserve KernelTestCase for the wiring contract (right service id, right marking store, right initial place).

Dumping graphs belongs in CI. bin/console workflow:dump order --dump-format=puml checked into the repo keeps the state model reviewable in pull requests. It is the cheapest way to catch an unreachable place or a transition pointing at a typo'd place name.

Gotchas and Tips

This package has no community. One star and zero dependents means no Stack Overflow answers, no blog posts, and no one to ask. The symfony/workflow component beneath it is mature and well documented — read that instead — but the bundle's own layer is unaudited. Before adopting: read the entire src/ directory (it is likely small enough to do in ten minutes), pin an exact version in composer.json, and have a plan to remove the bundle and configure framework.workflows directly if it stalls. Most of what such a bundle does is a few lines of YAML you can own yourself.

Silent guard failures are the number one debugging trap. A guard returning false stops the transition with no exception. Always pass a reason via setBlocked(), log it in a guard listener, and surface getEnabledTransitions() in your admin UI so operators see what is actually available.

initial_marking only applies to new objects. Existing rows with NULL or an unexpected status will fail can() and apply() in ways that look like broken config. Backfill in a migration, and add a validation listener on pre_persist-adjacent code paths so future rows cannot be created without a valid place.

Place and transition names share one namespace per workflow and are not validated against each other by default. A transition to: a place you never declared under places: throws at container compile time — but a typo that happens to match another transition's name does not. Dump the graph after every config change.

Changing YAML requires a cache clear even in dev when config caching is on, and metadata is compiled into the container. bin/console cache:clear after edits; if things look stale, check var/cache/*/Container*/ was actually rebuilt.

Event name construction is .-joined and easy to mistype: workflow.<name>.guard.<transition>, workflow.<name>.transition.<transition>, plus enter/leave/entered/left variants. A listener on a mistyped event name registers fine and never fires. Verify with bin/console debug:event-dispatcher workflow.order (Symfony 6.3+) rather than trusting the string.

apply() vs can() are not atomic. Between checking and applying, another request can change the marking. For anything with money or inventory attached, wrap the check-and-apply in a transaction with a row lock, or use optimistic locking on the entity's version column.

Marking store writes do not persist themselves. apply() mutates the in-memory object; you still need flush(). Forgetting this produces a workflow that "works" in tests and resets on every request.

Multiple workflows on one entity need distinct marking properties. Two workflows both writing $status will fight. Give each its own property and its own supports block; use workflow.<name> / state_machine.<name> service ids to disambiguate when injecting.

Autowiring workflows needs the argument name to match. WorkflowInterface $orderStateMachine resolves to state_machine.order or workflow.order depending on type — if you have both a workflow and a state_machine named order, autowiring is ambiguous and fails at compile time. Use #[Target('workflow.order')] or an explicit service alias.

Extension point if the bundle is too thin: register your own WorkflowGuardListener and WorkflowTransitionListener classes and tag them, rather than editing vendor code. If the bundle builds its own registry, look for a tagged-service collector in its compiler pass — that is usually the intended customization seam, and it is worth checking whether it exists before forking.

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.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
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