When controllers become orchestras (not the good kind)
Learn how to manage complex business logic in Laravel controllers using the Laravel Flows package for structured and maintainable workflows.
Complex business processes often start with simple intentions. A controller method begins by validating an order and calculating the price, and it concludes by charging the payment. Months later, that same method handles pricing rules, fraud checks, inventory reservations, VIP logic, shipping label creation, and a long list of edge cases. What began as a straightforward task becomes a large and difficult block of code.
The complexity of the business domain is not the issue. The real challenge appears when developers lack a consistent way to represent that complexity without creating large “god” service classes or splitting the logic into many small actions that require constant coordination.
Laravel Flows gives development teams a structured, fluent, and composable way to orchestrate business processes. It keeps workflows readable, testable, and maintainable as requirements grow.
What Flows solve?
Before looking at implementation details, it helps to clarify the common problems that Flows aims to solve. Teams that scale Laravel applications often face the same issues:
- Hidden branching logic: Optional behavior sits inside nested conditionals, which makes it harder to see how the process works.
- Scattered error handling: Try-catch blocks appear in many places, and failures behave inconsistently.
- Difficult testing: Mocking becomes complex because multiple concerns couple together.
- Slow onboarding: New developers need to read large sections of code to understand how a business process works.
Flows provides a domain-specific language for business workflows. It works like method chaining for processes, and each step acts as a testable and reusable unit.
Installation and core concepts
You can install the laravel-flows package with Composer:
composer require juststeveking/laravel-flows
The package relies on a few core abstractions:
- FlowStep: A class that implements
handle(mixed $payload, Closure $next): mixed. - FlowCondition: An invokable class that returns a boolean result based on the payload.
- Flow methods:
run(),branch(),runIf(),catch(), anddebug().
Each step follows a predictable pattern: receive the payload, perform an action, and pass the updated payload to the next step. There is no hidden behavior or implicit state. The design mirrors middleware and other pipeline-based patterns.
Building your first flow
Let's walk through a realistic example: an order-to-cash workflow. This type of process can grow complex and difficult to maintain if it sits inside a single controller or service. The workflow includes:
- Validating the order
- Normalizing data
- Calculating pricing and discounts
- Running risk checks
- Reserving inventory
- Authorizing payment
- Handling physical or digital fulfillment
- Auditing the outcome
Here's what this looks like with Flows:
final class OrderToCashWorkflow
{
public static function run(array $order): array
{
return Flow::start()->run(
ValidateOrder::class,
)->run(
NormaliseOrderData::class,
)->run(
CalculateBasePricing::class,
)->runIf(
fn($payload) => ($payload['customer']['vip'] ?? false) === true,
ApplyVipDiscounts::class,
)->run(
ApplyPromotions::class,
)->run(
RunRiskChecks::class,
)->run(
ReserveInventory::class,
)->run(
AuthorisePayment::class,
)->branch(
condition: IsPhysicalOrder::class,
callback: fn(array $payload): array -> Flow::start()
->run(CreateShippingLabel::class)
->run(NotifyWarehouse::class)
->execute($payload),
)->run(
DeliverDigitalGoods::class,
)->run(
AuditOrderOutcome::class
)->catch(function (Throwable $e, mixed $payload) {
$payload['error'] = [
'message' => $e->getMessage(),
'class' => get_class($e),
];
// trigger compensations here ...
return $payload;
})->execute($order);
}
}
This structure makes the business process easy to understand without reading the implementation of each step. The workflow becomes a form of executable documentation that shows the full path the order follows.
Implementing steps
Each step in a Flow is a standalone class. The examples below show the typical pattern: accept the payload, perform an action, and pass the result to the next step.
Validation step
final class ValidateOrder implements FlowStep
{
public function handle(mixed $payload, Closure $next): mixed
{
if (empty($payload['customer']['id'])) {
throw new InvalidArgumentException(
message: 'Missing customer id.',
);
}
if (empty($payload['items']) || !is_array($payload['items'])) {
throw new InvalidArgumentException(
message: 'Order must contain items.',
);
}
// Additional validations go here
return $next($payload);
}
}
This step checks core fields, throws an exception when validation fails, and forwards the payload when the data is valid. The central catch() handler manages errors, so you do not need to return error objects or status codes.
Pricing step
final class CalculateBasePricing implements FlowStep
{
public function handle(mixed $payload, Closure $next): mixed
{
$items = $payload['items'] ?? [];
$subtotal = 0;
foreach ($items as &$item) {
$unitPrice = $item['unit_price'] ?? 0;
$lineTotal = $unitPrice * ($item['quantity'] ?? 1);
$item['line_total'] = $lineTotal;
$subtotal += $lineTotal;
}
$payload['pricing'] = [
'subtotal' => $subtotal,
'discounts' => [],
'tax' => 0,
'total' => $subtotal,
];
return $next($payload);
}
}
This step applies pricing rules and updates the payload with calculated values. In a production system, a pricing service or database might provide this information, but the pattern stays the same: transform the payload and pass it forward.
Risk check step
Here's where things get a little fun/interesting.
final class RunRiskChecks implements FlowStep
{
public function handle(mixed $payload, Closure $next): mixed
{
$score = 0;
if (($payload['customer']['chargeback_flag'] ?? false) === true) {
$score += 50;
}
if (($payload['total_orders_last_24h'] ?? 0) > 5) {
$score += 20;
}
$payload['risk'] = [
'score' => $score,
'requires_manual_review' => $score >= 60,
];
if ($payload['risk']['requires_manual_review']) {
throw new RuntimeException(
message: 'Order requires manual review.',
);
}
return $next($payload);
}
}
This step evaluates simple risk rules and adds the result to the payload. If the order requires manual review, it throws an exception. The central error handler then applies consistent error formatting, logging, and compensation actions such as releasing inventory.
Making optional paths explicit
One notable feature of Flows is its handling of conditional logic. Instead of burying branches inside steps, you make them explicit at the workflow level.
Inline conditionals with runIf()
runIf() applies a step only when a given condition returns true.
->runIf(
fn(array $payload) => ($payload['customer']['vip'] ?? false) === true,
ApplyVipDiscounts::class,
)
This structure makes optional logic clear. The condition appears next to the step it affects, which removes the need to search through method bodies to find branching rules.
Complex branches with branch()
branch() supports multi-step subflows that run only when a specific condition is met.
->branch(
condition: IsPhysicalOrder::class,
callback: fn(array $payload): array => Flow::start()
->run(CreateShippingLabel::class)
->run(NotifyWarehouse::class)
->execute($payload)
)
This example applies a fulfillment path only when the order contains physical items. The condition sits inside a dedicated class:
final class IsPhysicalOrder implements FlowCondition
{
public function __invoke(mixed $payload): bool
{
return collect(
$payload['items'] ?? []
)->contains(
fn($item) => ($item['type'] ?? 'physical') === 'physical',
);
}
}
The condition remains clean, testable, and reusable across workflows.
Centralized error handling
Flows processes errors in a single location using catch(). This removes scattered try-catch blocks and ensures consistent failure behavior.
->catch(function(\Throwable $e, mixed $payload) {
// Normalize error structure
$payload['error'] = [
'message' => $e->getMessage(),
'class' => get_class($e),
'code' => $e->getCode(),
];
// Trigger compensating actions
if (isset($payload['inventory']['reservation_id'])) {
// Release the reserved inventory
Inventory::release($payload['inventory']['reservation_id']);
}
if (isset($payload['payment']['intent_id'])) {
// Cancel the payment intent
Payments::cancel($payload['payment']['intent_id']);
}
// Log to monitoring systems
Log::error('Order workflow failed', [
'order_id' => $payload['order_id'] ?? null,
'error' => $payload['error'],
]);
return $payload;
})
Now, every exception in your workflow is handled by this handler. You get consistent error structures, reliable compensations, and centralized monitoring. This is how you build resilient systems.
Observability with the debug() method
During development and troubleshooting, you need visibility into how data transforms through your workflow. Flows provides debug():
$result = Flow::start()
->debug(Log::channel('stack'))
->run(ValidateOrder::class)
->run(NormalizeOrderData::class)
->run(CalculateBasePricing::class)
->execute($order);
This configuration logs the payload before and after each step. When an error occurs, these logs help identify where the data changed unexpectedly or which condition caused the failure.
Reusable subflows
Large applications often reuse the same sequence of steps across multiple workflows. Subflows make these sequences easy to package and share.
final class PhysicalFulfillmentFlow
{
public function __invoke(Flow $flow): void
{
$flow->run(CreateShippingLabel::class)
->run(NotifyWarehouse::class)
->run(SendShippingNotification::class);
}
}
You can include this subflow in any workflow:
$flow = Flow::start()
->with(new PhysicalFulfillmentFlow())
->run(AuditOrderOutcome::class);
This approach keeps fulfillment logic independent from pricing, risk checks, and other processes. As systems grow, reusable subflows act as maintainable building blocks.
Testing strategy for production teams
Let's talk about testing, because this is where the architecture pays off:
Unit testing steps
Each step behaves deterministically. You can mock external dependencies and assert the transformation of the payload:
#[Test]
public function validate_order_rejects_missing_customer(): void
{
$step = new ValidateOrder();
$payload = ['items' => [['sku' => 'TEST']]];
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Missing customer id.');
$step->handle($payload, fn($p) => $p);
}
Integration testing workflows
Integration tests confirm how the full workflow behaves with complete order data:
#[Test]
public function vip_order_receives_discounts(): void
{
$order = [
'customer' => ['id' => 'cus_1', 'vip' => true],
'items' => [['sku' => 'TEST', 'unit_price' => 100, 'quantity' => 1]],
];
$result = OrderToCashWorkflow::run($order);
$this->assertTrue($result['pricing']['discounts']->isNotEmpty());
$this->assertLessThan(100, $result['pricing']['total']);
}
Testing failure paths
You can also simulate errors to confirm that compensating actions run correctly:
#[Test]
public function risk_failure_releases_inventory(): void
{
$order = [
'customer' => ['id' => 'cus_1', 'chargeback_flag' => true],
'items' => [['sku' => 'TEST']],
'total_orders_last_24h' => 10,
];
$result = OrderToCashWorkflow::run($order);
$this->assertArrayHasKey('error', $result);
// Assert inventory was released via your mocking strategy
}
When to use Flows (and when not to)
Flows works best for synchronous orchestration inside a request or job. Use it when:
- You coordinate multiple business rules within one execution path.
- Optional behavior exists, such as VIP handling or different fulfillment paths.
- You want clear and testable separation of concerns.
- You need consistent and predictable error handling.
Avoid using Flows when:
- You need durable workflows with retries or compensation across long time periods.
- Human approval steps are part of the process.
- You manage distributed saga patterns across microservices.
- Your workflow depends on timeouts or scheduled retries.
In these scenarios, use a dedicated workflow engine. Flows can complement those systems by handling synchronous logic inside each durable workflow step.
Performance and architecture considerations
Keep the following practices in mind when designing workflows:
- Keep steps pure. Avoid hidden I/O or side effects. Each step should remain deterministic based on its payload, which improves testing and debugging.
- Make branches explicit. Do not hide optional paths inside step logic. Use
runIf()andbranch()to make branching behavior visible at the workflow level. - Limit payload size. The payload moves through every step. Keep it small, use IDs where possible, and load full models only when needed.
- Use composition over inheritance. Build workflows from small, reusable steps instead of step hierarchies.
Real-world impact
Teams that adopt Flows often report improvements such as:
- Faster onboarding. Developers can read the workflow definition and understand the business process quickly.
- Safer refactoring. Steps follow consistent contracts, which reduces the risk of reordering or replacing logic.
- Higher test coverage. Small steps and clear orchestration patterns support both unit and integration testing.
- Better incident response. The
debug()method and centralized error handling make production issues easier to diagnose.
Wrapping up
Complex business processes don't have to become unmaintainable messes. Laravel Flows gives you a vocabulary for expressing orchestration that stays readable as requirements evolve. It's a lightweight abstraction that leverages Laravel's strengths while keeping your code testable and clear.
Next steps:
- Install the package:
composer require juststeveking/laravel-flows - Identify one complex business method in your codebase
- Model it as a Flow with small, focused steps
- Extract optional behaviours into
runIf()orbranch() - Add centralised error handling with
catch() - Enable
debug()in your local environment
The package is on GitHub at JustSteveKing/laravel-flows. Check out the documentation, explore the source, and start turning your complex processes into clean, composable pipelines.