juststeveking/laravel-business-process
Define business processes as explicit steps and transitions, then run them predictably in your Laravel app. Encapsulates multi-step workflow logic in dedicated classes, keeping controllers and models clean, and making complex flows easier to test, reason about, and maintain.
Install and confirm the surface area first. The package is small enough (and pre-1.0 enough) that the source is the documentation:
composer require juststeveking/laravel-business-process
Laravel auto-discovers the service provider, so there is usually nothing to register by hand. Before writing any domain code, open vendor/juststeveking/laravel-business-process/src and read two things: the abstract Process class (the hooks you implement — likely passable() and pipes()) and the Passable contract (the interface your data object must satisfy). Confirm the exact method names and the run() signature against the source rather than a blog post; they are the whole API you will live in.
Model your first process as a typed pipeline. A "process" is a named class that describes an ordered list of steps ("pipes") executed over a typed data object ("the passable"). Pick one bounded, side-effect-light workflow you already do with ad-hoc if statements — an onboarding flow, an order-placement sequence — and express it:
use JustSteveKing\BusinessProcess\Process;
final class PlaceOrderProcess extends Process
{
public function passable(): Passable
{
return new PlaceOrderPassable(...);
}
public function pipes(): array
{
return [
ValidateCart::class,
ReserveStock::class,
ChargePayment::class,
CreateOrderRecord::class,
SendOrderConfirmation::class,
];
}
}
Run it as one call from the edge of your app. The value proposition is a single entry point you can invoke from a controller, a queued job, an artisan command, or a test:
$result = PlaceOrderProcess::run($passable); // confirm the exact entrypoint in src
Where to look first: the Process abstract, the Passable contract, then any bundled example process. That reading order gives you the pattern before you copy it.
One process per workflow, named after the domain verb. PlaceOrderProcess, OnboardCustomerProcess, ApproveClaimProcess. The process class is the reviewable artifact — a PR that changes the order lifecycle should show up as a diff to PlaceOrderProcess::pipes(), which is exactly the shared vocabulary you want in code review.
The passable is a DTO with explicit mutability. Pipes read from and write back to the same object as it flows through. Decide up front whether that object is immutable (each pipe returns a new instance) or mutable (pipes enrich in place). Mutable accumulation is the common, pragmatic choice — an Order being built step by step — but make the mutation intentional and documented, because it is the main source of "why is this field set?" surprises later.
Pipes are single-responsibility classes, resolved from the container. Because the pipeline instantiates each pipe through Laravel's container, you get constructor dependency injection for free:
final class ChargePayment
{
public function __construct(private PaymentGateway $gateway) {}
public function handle(PlaceOrderPassable $passable, Closure $next)
{
$passable->payment = $this->gateway->charge($passable->cart);
return $next($passable);
}
}
Keep each pipe to one business fact. "Reserve stock" is a pipe; "reserve stock, charge payment, and email the customer" is three.
Express guards as early-return pipes, not branching inside later steps. A validation or guard pipe runs first and either continues or aborts. This is how you get the "invalid transition can never slip through" property the package markets — a fail-fast pipe at the head of the sequence, not a spray of defensive ifs inside every step.
Decide where side effects live. Emails, events, and external calls inside pipes mean a failure aborts mid-flight with no rollback of what already happened. Two durable patterns: (a) wrap the whole process in DB::transaction when steps are DB-only, or (b) keep the process pure-ish and dispatch external side effects (mail, webhooks) as queued jobs after the process returns successfully. Mixing DB transaction boundaries with external HTTP calls is the most common way these pipelines bite.
Call it from the edges, not the middle. Controllers, jobs, and commands call the process; the process never calls back into a controller-shaped concern. This keeps the process unit-testable and reusable across entry points (a web request and a retry job run the same process).
Wrap it behind an app-owned interface before the second consumer. Introduce a ProcessRunner / TransitionService in your own namespace, and let callers depend on that rather than importing the package namespace directly. Given the 0.0.1 maturity, this wrapper is the single most valuable decision: it turns an irreversible architecture bet into a reversible one and gives you a seam to add logging, locking, and reconciliation later.
Test at two levels. A test per pipe asserting its one behavior with a hand-built passable, plus one end-to-end test running the full process and asserting the resulting passable/DB state. Because the passable is a plain typed object, you can exercise a pipe with no Eloquent boot at all — use that.
Pin the exact version or a patch-only constraint. At 0.x, semver permits breaking changes in every minor bump. A caret range (^0.0.1) is an invitation to a surprise on the next composer update. Record the pinned version in a short ADR alongside why the package was chosen and what would trigger its removal.
Understand what run() returns. Pipelines typically return the final passable — but confirm whether it returns the passable or the pipe's return value, and where then() vs thenReturn() semantics land. A mismatched assumption here silently swallows the result you cared about. Read the abstract Process to see which pipeline terminal method it uses.
An exception aborts the pipeline — with no compensation. There is no built-in "undo" for steps that already ran. If step three throws, steps one and two have already mutated state. Choose transaction-wrapping or side-effect-deferral deliberately (see above); don't assume atomicity.
There is likely no transition history or audit trail. The passable carries current state; it does not persist a log of who moved it where. If you need "where is this record stuck?" answered by support, write that history yourself inside the relevant pipe (or via model events). Verify against source before promising auditability to stakeholders.
Concurrency is yours to handle. Nothing in a single-app pipeline locks the underlying entity. Two concurrent requests can both run the process against the same record. Add row-level locking (lockForUpdate) at the caller or wrap the process in a serialized job if double-execution matters.
No durable, cross-service orchestration. If a "process" needs retries across service boundaries, resumption after a multi-hour wait, or a visual BPMN author, this is the wrong tool. This is in-process orchestration; say so explicitly before a spike starts so nobody expects Temporal semantics.
Debugging: drop a logging pipe. Because steps are container-resolved classes, the fastest diagnosis is a temporary pipe that logger()->info(...) (or dumps) the passable between two real steps. Pair with Telescope — each pipe's container resolution and DB queries show up normally.
Container resolution quirks. Pipes needing constructor args must be resolvable from the container. If a pipe needs a runtime value (a specific tenant, a config flag), pass it via the passable rather than trying to bind it. Bind only stable services.
Abandonment cost is the real risk, and the wrapper caps it. Keep the package surface confined to your ProcessRunner adapter and the process definition classes. If upstream goes quiet (last release 2023, single maintainer, zero dependents), self-patching or forking should be a day's work, not a quarter's — provided nothing outside those files imports the package namespace.
Extension points worth knowing: custom passable implementations (one per process vs a shared base), a reusable base pipe class if you want common logging/metrics, and model/job-level hooks where the process is invoked. Confirm which of these the package actually exposes versus which you build around it — at 0.0.1, assume you own more of the plumbing than the README implies.
How can I help you explore Laravel packages today?