Chevere Workflow for Laravel is a PHP library for building and executing multi-step procedures with automatic dependency resolution. Define independent jobs that can run synchronously or asynchronously, pass data between them using typed responses, and let the engine handle execution order automatically.
Key features:
- Declarative job definitions: Define what to do, not how to orchestrate it
- Automatic dependency graph: Jobs execute in optimal order based on their dependencies
- Sync and async execution: Mix blocking and non-blocking jobs freely
- Type-safe responses: Access job outputs with full type safety
- Conditional execution: Run jobs based on variables or previous responses
- Built-in retry policies: Handle transient failures automatically
- Testable: Each job is independently testable and workflow graph can be verified
You define jobs and how they connect and depend on each other, Chevere Workflow figures out the execution order and runs them accordingly.
- Dependency Management: Like
Bus::chain(), execution order follows dependencies. But instead of manually sequencing every step, simply declare what data each job needs withresponse()and the engine infers the order. - Parallel Processing: Like
Bus::batch(), independent jobs run concurrently usingasync(). - Sequential Flows: Like
Pipeline, you can model linear operations but workflow graphs also handle branching and fan-in without custom plumbing. - Conditional Execution: Like feature flags or guard clauses, jobs can be skipped using declarative
withRunIf()andwithRunIfNot().
This Laravel package is a thin wrapper around chevere/workflow. It provides:
| Component | What It Does |
|---|---|
WorkflowServiceProvider |
Registers the workflow system with Laravel's service container |
AbstractWorkflow |
Base class you extend to define workflows (like Laravel's Mailable or Notification) |
WorkflowManager |
Service that runs workflows with Laravel's container for dependency injection |
Workflow Facade |
Static access (Workflow::run(...)) for convenience |
| Artisan Commands | make:workflow, workflow:run, workflow:list |
This integration uses a PSR-11 bridge backed by Laravel's container, so your Laravel services (Eloquent models, mailers, cache, etc.) are automatically available for dependency injection inside workflow jobs.
composer require chevere/workflow-laravelphp artisan workflow:listYou should see: No workflows found.
php artisan make:workflow GreetUserThis creates app/Workflows/GreetUser.php:
namespace App\Workflows;
use Chevere\Workflow\Interfaces\WorkflowInterface;
use Chevere\Workflow\Laravel\AbstractWorkflow;
use function Chevere\Workflow\sync;
use function Chevere\Workflow\variable;
use function Chevere\Workflow\workflow;
class GreetUser extends AbstractWorkflow
{
protected function definition(): WorkflowInterface
{
return workflow(
greet: sync(
fn(string $name): string => "Hello, {$name}!",
name: variable('name'),
),
);
}
}namespace App\Http\Controllers;
use App\Workflows\GreetUser;
use Illuminate\Http\JsonResponse;
class GreetController extends Controller
{
public function __invoke(GreetUser $workflow): JsonResponse
{
$run = $workflow->run(name: 'Taylor');
return response()->json([
'message' => $run->response('greet')->string(),
// "Hello, Taylor!"
]);
}
}use Chevere\Workflow\Laravel\Facades\Workflow;
use App\Workflows\GreetUser;
$run = Workflow::run(GreetUser::class, name: 'Taylor');
echo $run->response('greet')->string(); // "Hello, Taylor!"Chevere Workflow uses jobs, variables, and responses to build a dependency graph that automatically determines execution order. Jobs can be sync() or async(), variables are runtime inputs declared with variable(), and responses reference outputs from previous jobs using response().
For detailed information about these core concepts, execution graphs, and how the workflow engine works, see the Chevere Workflow documentation.
Laravel-specific behavior: When you extend AbstractWorkflow, Laravel's service container is automatically available for dependency injection in your job callables.
This is the main class you extend. It follows the same pattern as Laravel's Mailable, Notification, or FormRequest:
namespace App\Workflows;
use Chevere\Workflow\Interfaces\WorkflowInterface;
use Chevere\Workflow\Laravel\AbstractWorkflow;
use function Chevere\Workflow\{workflow, sync, async, variable, response};
class ProcessOrder extends AbstractWorkflow
{
protected function definition(): WorkflowInterface
{
return workflow(
validate: sync(
fn(int $orderId): array => ['id' => $orderId, 'valid' => true],
orderId: variable('orderId'),
),
charge: sync(
fn(array $order): array => ['charged' => true, 'amount' => 99.99],
order: response('validate'),
),
sendReceipt: async(
fn(array $charge): bool => true,
charge: response('charge'),
),
updateInventory: async(
fn(array $order): bool => true,
order: response('validate'),
),
);
}
}| Method | Returns | Description |
|---|---|---|
run(mixed ...$variables) |
RunInterface |
Execute the workflow |
getWorkflow() |
WorkflowInterface |
Get the workflow definition (cached) |
graph() |
array<array<string>> |
Get the execution graph |
Laravel auto-injects AbstractWorkflow subclasses via the container:
class OrderController extends Controller
{
public function store(ProcessOrder $workflow, Request $request): JsonResponse
{
$run = $workflow->run(orderId: $request->input('order_id'));
return response()->json([
'charged' => $run->response('charge', 'charged')->bool(),
'amount' => $run->response('charge', 'amount')->float(),
]);
}
}The Workflow facade provides static access to the WorkflowManager:
use Chevere\Workflow\Laravel\Facades\Workflow;
// Run a workflow class
$run = Workflow::run(ProcessOrder::class, orderId: 42);
// Run an inline workflow definition
use function Chevere\Workflow\{workflow, sync, variable};
$run = Workflow::run(
workflow(
greet: sync(
fn(string $name): string => "Hello, {$name}!",
name: variable('name'),
),
),
name: 'World'
);Inject WorkflowManager when you prefer dependency injection over facades:
use Chevere\Workflow\Laravel\WorkflowManager;
class OrderService
{
public function __construct(
private WorkflowManager $workflows
) {}
public function processOrder(int $orderId): array
{
$run = $this->workflows->run(ProcessOrder::class, orderId: $orderId);
return $run->toArray();
}
}Generate a new workflow class:
php artisan make:workflow ProcessOrder
# Creates: app/Workflows/ProcessOrder.phpRun a workflow from the command line:
php artisan workflow:run "App\Workflows\GreetUser" --var=name=Artisanworkflow:run accepts any class that extends Chevere\Workflow\Laravel\AbstractWorkflow.
Output:
Running workflow: App\Workflows\GreetUser
Execution graph:
Level 0: greet
Workflow completed successfully.
UUID: a1b2c3d4-...
Responses:
greet: Hello, Artisan!
Pass multiple variables:
php artisan workflow:run "App\Workflows\ProcessOrder" --var=orderId=42 --var=notify=trueList all workflows found in the application:
php artisan workflow:listOutput:
+-----------------------------------------+--------+-------------+
| Workflow | Jobs | Graph Depth |
+-----------------------------------------+--------+-------------+
| App\Workflows\ProcessOrder | 4 jobs | 3 levels |
| App\Workflows\ImageResize | 4 jobs | 2 levels |
+-----------------------------------------+--------+-------------+
Workflows are auto-discovered by scanning your application's app/ directory for classes extending AbstractWorkflow.
This is the best starting point for Laravel developers. It mirrors what you'd normally build with events/listeners or job chains, but with automatic ordering.
namespace App\Workflows;
use App\Actions\CreateUser;
use App\Actions\SendWelcomeEmail;
use App\Actions\LogRegistration;
use App\Actions\AssignDefaultRole;
use Chevere\Workflow\Interfaces\WorkflowInterface;
use Chevere\Workflow\Laravel\AbstractWorkflow;
use function Chevere\Workflow\{workflow, sync, async, variable, response};
class RegisterUser extends AbstractWorkflow
{
protected function definition(): WorkflowInterface
{
return workflow(
// Step 1: Validate and create the user (must be sync — everything else depends on it)
createUser: sync(
CreateUser::class,
email: variable('email'),
password: variable('password'),
name: variable('name'),
),
// Step 2a: Send welcome email (async — can run async with 2b and 2c)
sendWelcome: async(
SendWelcomeEmail::class,
userId: response('createUser', 'id'),
email: response('createUser', 'email'),
),
// Step 2b: Log the registration (async — runs async with 2a and 2c)
logEvent: async(
LogRegistration::class,
userId: response('createUser', 'id'),
),
// Step 2c: Assign default role (async — runs async with 2a and 2b)
assignRole: async(
AssignDefaultRole::class,
userId: response('createUser', 'id'),
role: 'member',
),
);
}
}Execution graph:
Level 0: [createUser] ← runs first
Level 1: [sendWelcome, logEvent, assignRole] ← run async after createUser
In your controller:
class RegisterController extends Controller
{
public function store(Request $request, RegisterUser $workflow): JsonResponse
{
$request->validate([
'email' => 'required|email|unique:users',
'password' => 'required|min:8',
'name' => 'required|string',
]);
$run = $workflow->run(
email: $request->input('email'),
password: $request->input('password'),
name: $request->input('name'),
);
return response()->json([
'user_id' => $run->response('createUser', 'id')->int(),
'message' => 'Registration complete',
], 201);
}
}What each Action class looks like (using chevere/action):
namespace App\Actions;
use App\Models\User;
use Chevere\Action\Action;
use Illuminate\Support\Facades\Hash;
class CreateUser extends Action
{
public function __invoke(string $email, string $password, string $name): array
{
$user = User::create([
'email' => $email,
'password' => Hash::make($password),
'name' => $name,
]);
return ['id' => $user->id, 'email' => $user->email];
}
}You can also use plain closures instead of Action classes — see the Quick Start example.
Process multiple image sizes async, then store them all:
namespace App\Workflows;
use Chevere\Workflow\Interfaces\WorkflowInterface;
use Chevere\Workflow\Laravel\AbstractWorkflow;
use Illuminate\Support\Facades\Storage;
use function Chevere\Workflow\{workflow, sync, async, variable, response};
class ProcessImage extends AbstractWorkflow
{
protected function definition(): WorkflowInterface
{
return workflow(
// These 3 resize jobs run async (no dependencies on each other)
thumb: async(
fn(string $path): string => $this->resize($path, 150, 150),
path: variable('imagePath'),
),
medium: async(
fn(string $path): string => $this->resize($path, 800, 600),
path: variable('imagePath'),
),
large: async(
fn(string $path): string => $this->resize($path, 1920, 1080),
path: variable('imagePath'),
),
// This runs after ALL three above complete
store: sync(
fn(string $thumb, string $medium, string $large): array => [
'thumb' => Storage::put('thumbs', $thumb),
'medium' => Storage::put('medium', $medium),
'large' => Storage::put('large', $large),
],
thumb: response('thumb'),
medium: response('medium'),
large: response('large'),
),
);
}
private function resize(string $path, int $w, int $h): string
{
// Your image processing logic (Intervention Image, GD, Imagick, etc.)
return "/resized/{$w}x{$h}/" . basename($path);
}
}Execution graph:
Level 0: [thumb, medium, large] ← async
Level 1: [store] ← after all three
This example demonstrates using withRunIf() for conditional job execution (learn more):
namespace App\Workflows;
use Chevere\Workflow\Interfaces\WorkflowInterface;
use Chevere\Workflow\Laravel\AbstractWorkflow;
use function Chevere\Workflow\{workflow, sync, async, variable, response};
class ProcessOrder extends AbstractWorkflow
{
protected function definition(): WorkflowInterface
{
return workflow(
validate: sync(
fn(int $orderId): array => ['id' => $orderId, 'total' => 150.00, 'valid' => true],
orderId: variable('orderId'),
),
charge: sync(
fn(array $order): array => [
'transactionId' => 'txn_' . $order['id'],
'amount' => $order['total'],
],
order: response('validate'),
),
// Conditionally send receipt based on runtime variable
sendReceipt: async(
fn(string $txnId, float $amount): bool => true,
txnId: response('charge', 'transactionId'),
amount: response('charge', 'amount'),
)->withRunIf(
variable('sendReceipt')
),
);
}
}Fetch data from multiple external APIs async:
namespace App\Workflows;
use Chevere\Workflow\Interfaces\WorkflowInterface;
use Chevere\Workflow\Laravel\AbstractWorkflow;
use Illuminate\Support\Facades\Http;
use function Chevere\Workflow\{workflow, sync, async, variable, response};
class FetchDashboardData extends AbstractWorkflow
{
protected function definition(): WorkflowInterface
{
return workflow(
// All three API calls run async
weather: async(
fn(string $city): array => Http::get("https://api.weather.example/v1/{$city}")->json(),
city: variable('city'),
),
news: async(
fn(string $category): array => Http::get("https://api.news.example/v1/{$category}")->json(),
category: variable('newsCategory'),
),
stocks: async(
fn(string $symbol): array => Http::get("https://api.stocks.example/v1/{$symbol}")->json(),
symbol: variable('stockSymbol'),
),
// Combine all results after they arrive
dashboard: sync(
fn(array $weather, array $news, array $stocks): array => [
'weather' => $weather,
'news' => $news,
'stocks' => $stocks,
'generated_at' => now()->toISOString(),
],
weather: response('weather'),
news: response('news'),
stocks: response('stocks'),
),
);
}
}Without Workflow, those 3 API calls would run sequentially (3x the latency). With async(), they run concurrently.
Extract, transform, and load data with clear step separation:
namespace App\Workflows;
use Chevere\Workflow\Interfaces\WorkflowInterface;
use Chevere\Workflow\Laravel\AbstractWorkflow;
use function Chevere\Workflow\{workflow, sync, variable, response};
class ImportCsvData extends AbstractWorkflow
{
protected function definition(): WorkflowInterface
{
return workflow(
// Extract: read and parse CSV
extract: sync(
fn(string $path): array => array_map('str_getcsv', file($path)),
path: variable('csvPath'),
),
// Transform: clean and validate rows
transform: sync(
fn(array $rows): array => array_filter($rows, fn($row) => count($row) >= 3),
rows: response('extract'),
),
// Load: insert into database
load: sync(
fn(array $rows): int => count($rows), // DB::table('imports')->insert($rows)
rows: response('transform'),
),
);
}
}Chevere Workflow supports several advanced features:
- Conditional Execution: Use
withRunIf()andwithRunIfNot()to conditionally skip jobs based on variables or previous job responses - Retry Policies: Configure automatic retries with
withRetry()for transient failures - Sync/Async Execution: Use
sync()for sequential jobs andasync()for async execution - Mermaid Graphs: Visualize your workflow's execution graph with Mermaid syntax
For detailed documentation on these features, see the Chevere Workflow documentation.
This is where the Laravel integration shines. Laravel's container is automatically passed to Chevere Workflow, so your Action classes can use constructor injection:
use Chevere\Action\Action;
use App\Services\PaymentGateway;
use Illuminate\Log\LogManager;
class ChargePayment extends Action
{
public function __construct(
private PaymentGateway $gateway, // ← auto-injected from Laravel
private LogManager $logger, // ← auto-injected from Laravel
) {}
public function __invoke(int $orderId, float $amount): array
{
$this->logger->info("Charging order {$orderId}: \${$amount}");
$result = $this->gateway->charge($amount);
return ['transactionId' => $result->id, 'status' => $result->status];
}
}Any service registered in Laravel's container can be injected. No extra configuration needed.
When a job fails, Chevere Workflow wraps the original exception in a WorkflowException:
use Chevere\Workflow\Exceptions\WorkflowException;
try {
$run = $workflow->run(orderId: 42);
} catch (WorkflowException $e) {
// Which job failed?
echo $e->name; // "charge"
// The job instance
$job = $e->job;
// The original exception
$original = $e->throwable;
echo $original->getMessage();
}In a Laravel controller:
public function store(Request $request, ProcessOrder $workflow): JsonResponse
{
try {
$run = $workflow->run(orderId: $request->input('order_id'));
return response()->json($run->toArray());
} catch (WorkflowException $e) {
report($e->throwable); // Log with Laravel
return response()->json([
'error' => "Job '{$e->name}' failed: " . $e->throwable->getMessage(),
], 500);
}
}In a Laravel app consuming this package, use your application's base test case as usual:
namespace Tests\Feature;
use Tests\TestCase;
class ProcessOrderTest extends TestCase
{
// ...
}If you are developing this package itself (or another package), use Orchestra Testbench and register WorkflowServiceProvider:
use Chevere\Workflow\Laravel\WorkflowServiceProvider;
use Orchestra\Testbench\TestCase;
class ProcessOrderTest extends TestCase
{
protected function getPackageProviders($app): array
{
return [WorkflowServiceProvider::class];
}
}Verify jobs run in the expected order:
public function testGraphOrder(): void
{
$workflow = $this->app->make(ProcessOrder::class);
$graph = $workflow->graph();
// Level 0: validate runs first
$this->assertSame(['validate'], $graph[0]);
// Level 1: charge runs after validate
$this->assertSame(['charge'], $graph[1]);
// Level 2: sendReceipt, updateInventory run async
$this->assertContains('sendReceipt', $graph[2]);
$this->assertContains('updateInventory', $graph[2]);
}public function testWorkflowResponses(): void
{
$workflow = $this->app->make(ProcessOrder::class);
$run = $workflow->run(orderId: 42, sendReceipt: true, hasDiscount: false);
$this->assertTrue($run->response('charge', 'charged')->bool());
$this->assertSame(42, $run->response('validate', 'id')->int());
}public function testSkippedJobs(): void
{
$workflow = $this->app->make(ProcessOrder::class);
$run = $workflow->run(orderId: 42, sendReceipt: false, hasDiscount: false);
$this->assertTrue($run->skip()->contains('sendReceipt'));
$this->assertTrue($run->skip()->contains('applyDiscount'));
}use Chevere\Workflow\Exceptions\WorkflowException;
public function testJobFailure(): void
{
$this->expectException(WorkflowException::class);
$workflow = $this->app->make(FailingWorkflow::class);
$workflow->run(input: 'invalid');
}use Chevere\Workflow\Laravel\Facades\Workflow;
public function testFacade(): void
{
$run = Workflow::run(ProcessOrder::class, orderId: 42);
$this->assertNotEmpty($run->uuid());
}Laravel Queues push jobs to a queue worker (Redis, SQS, database) for background processing. Chevere Workflow runs jobs in the current process (with AMP async for concurrency). Use queues when you need background processing; use workflows when you need coordinated multi-step logic with data flowing between steps. By the way, you can dispatch a queued job that internally runs a workflow!
Yes. Laravel's container is passed to the workflow engine, so any service registered in the container (including Eloquent, facades, etc.) works normally.
Yes. You can dispatch a queued job that internally runs a workflow. The workflow itself runs synchronously/async within the queue worker process.
class ProcessOrderJob implements ShouldQueue
{
public function handle(ProcessOrder $workflow): void
{
$workflow->run(orderId: $this->orderId);
}
}Yes:
>>> Chevere\Workflow\Laravel\Facades\Workflow::run(App\Workflows\GreetUser::class, name: 'Tinker')Documentation is available at chevere.org/packages/workflow-laravel.
Copyright Rodolfo Berrios A.
Chevere is licensed under the Apache License, Version 2.0. See LICENSE for the full license text.
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.