-
-
Notifications
You must be signed in to change notification settings - Fork 0
PHP Steps
Jean-Marc Strauven edited this page Aug 6, 2025
·
1 revision
β YAML Flow Structure | Home | Step Groups β
PHP Steps are the building blocks of Laravel Flowpipe workflows. They contain your business logic and are executed by the YAML flow definitions.
A PHP Step is a class that implements the FlowStep
interface. It receives data (payload), processes it, and passes it to the next step in the pipeline.
<?php
namespace App\Flowpipe\Steps;
use Closure;
use Grazulex\LaravelFlowpipe\Contracts\FlowStep;
class MyStep implements FlowStep
{
public function handle(mixed $payload, Closure $next): mixed
{
// 1. Receive payload from previous step
// 2. Process the data
// 3. Pass modified payload to next step
return $next($modifiedPayload);
}
}
Every step must implement the FlowStep
contract:
<?php
namespace Grazulex\LaravelFlowpipe\Contracts;
use Closure;
interface FlowStep
{
/**
* Handle the step execution
*
* @param mixed $payload Data from previous step
* @param Closure $next Callback to next step
* @return mixed Modified payload
*/
public function handle(mixed $payload, Closure $next): mixed;
}
<?php
namespace App\Flowpipe\Steps;
use Closure;
use Grazulex\LaravelFlowpipe\Contracts\FlowStep;
class NormalizeEmailStep implements FlowStep
{
public function handle(mixed $payload, Closure $next): mixed
{
if (isset($payload['email'])) {
$payload['email'] = strtolower(trim($payload['email']));
}
return $next($payload);
}
}
<?php
namespace App\Flowpipe\Steps;
use Closure;
use Grazulex\LaravelFlowpipe\Contracts\FlowStep;
use Illuminate\Support\Facades\Validator;
use InvalidArgumentException;
class ValidateUserInputStep implements FlowStep
{
public function handle(mixed $payload, Closure $next): mixed
{
$validator = Validator::make($payload, [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|min:8|confirmed',
]);
if ($validator->fails()) {
throw new InvalidArgumentException(
'Validation failed: ' . implode(', ', $validator->errors()->all())
);
}
return $next($validator->validated());
}
}
Steps support Laravel's dependency injection:
<?php
namespace App\Flowpipe\Steps;
use Closure;
use Grazulex\LaravelFlowpipe\Contracts\FlowStep;
use App\Services\EmailService;
use App\Repositories\UserRepository;
use Illuminate\Support\Facades\Log;
class CreateUserStep implements FlowStep
{
public function __construct(
private UserRepository $userRepository,
private EmailService $emailService
) {}
public function handle(mixed $payload, Closure $next): mixed
{
$user = $this->userRepository->create([
'name' => $payload['name'],
'email' => $payload['email'],
'password' => bcrypt($payload['password']),
]);
Log::info('User created', ['user_id' => $user->id]);
// Add user to payload for next steps
$payload['user'] = $user;
$payload['user_id'] = $user->id;
return $next($payload);
}
}
Accept configuration from YAML:
<?php
namespace App\Flowpipe\Steps;
use Closure;
use Grazulex\LaravelFlowpipe\Contracts\FlowStep;
use Illuminate\Http\Client\Factory as HttpClient;
class ApiCallStep implements FlowStep
{
public function __construct(
private HttpClient $http,
private array $config = []
) {}
public function handle(mixed $payload, Closure $next): mixed
{
$endpoint = $this->config['endpoint'] ?? 'https://api.example.com';
$timeout = $this->config['timeout'] ?? 30;
$method = $this->config['method'] ?? 'POST';
$response = $this->http->timeout($timeout)
->$method($endpoint, $payload);
if ($response->failed()) {
throw new \Exception("API call failed: " . $response->body());
}
$payload['api_response'] = $response->json();
return $next($payload);
}
}
YAML usage:
- type: action
class: App\Flowpipe\Steps\ApiCallStep
config:
endpoint: "https://api.stripe.com/v1/charges"
method: "POST"
timeout: 45
<?php
namespace App\Flowpipe\Steps;
use Closure;
use Grazulex\LaravelFlowpipe\Contracts\FlowStep;
class ConditionalProcessingStep implements FlowStep
{
public function handle(mixed $payload, Closure $next): mixed
{
// Only process if certain conditions are met
if (!isset($payload['process_required']) || !$payload['process_required']) {
// Skip processing but continue flow
return $next($payload);
}
// Perform processing
$payload['processed_at'] = now();
$payload['status'] = 'processed';
return $next($payload);
}
}
<?php
namespace App\Flowpipe\Steps;
use Closure;
use Grazulex\LaravelFlowpipe\Contracts\FlowStep;
use App\Models\User;
class EnrichUserDataStep implements FlowStep
{
public function handle(mixed $payload, Closure $next): mixed
{
if (isset($payload['user_id'])) {
$user = User::with(['profile', 'preferences'])->find($payload['user_id']);
if ($user) {
$payload['user_profile'] = $user->profile->toArray();
$payload['user_preferences'] = $user->preferences->toArray();
$payload['is_premium'] = $user->hasSubscription('premium');
}
}
return $next($payload);
}
}
<?php
namespace App\Flowpipe\Steps;
use Closure;
use Grazulex\LaravelFlowpipe\Contracts\FlowStep;
class TransformOrderDataStep implements FlowStep
{
public function handle(mixed $payload, Closure $next): mixed
{
if (isset($payload['items'])) {
$payload['total_amount'] = collect($payload['items'])
->sum(fn($item) => $item['price'] * $item['quantity']);
$payload['item_count'] = collect($payload['items'])
->sum('quantity');
$payload['has_digital_items'] = collect($payload['items'])
->contains('type', 'digital');
}
return $next($payload);
}
}
<?php
namespace App\Flowpipe\Steps;
use Closure;
use Grazulex\LaravelFlowpipe\Contracts\FlowStep;
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderConfirmationMail;
class SendOrderConfirmationStep implements FlowStep
{
public function handle(mixed $payload, Closure $next): mixed
{
if (isset($payload['user'], $payload['order'])) {
Mail::to($payload['user'])->send(
new OrderConfirmationMail($payload['order'])
);
$payload['confirmation_sent'] = true;
$payload['confirmation_sent_at'] = now();
}
// Continue flow even if email fails (non-critical)
return $next($payload);
}
}
<?php
namespace App\Flowpipe\Steps;
use Closure;
use Grazulex\LaravelFlowpipe\Contracts\FlowStep;
use App\Exceptions\InsufficientInventoryException;
class CheckInventoryStep implements FlowStep
{
public function handle(mixed $payload, Closure $next): mixed
{
foreach ($payload['items'] as $item) {
if ($this->getStock($item['sku']) < $item['quantity']) {
throw new InsufficientInventoryException(
"Insufficient stock for {$item['sku']}"
);
}
}
return $next($payload);
}
private function getStock(string $sku): int
{
// Check inventory logic
return 0;
}
}
<?php
namespace App\Flowpipe\Steps;
use Closure;
use Grazulex\LaravelFlowpipe\Contracts\FlowStep;
use Illuminate\Support\Facades\Log;
class OptionalNotificationStep implements FlowStep
{
public function handle(mixed $payload, Closure $next): mixed
{
try {
$this->sendNotification($payload);
$payload['notification_sent'] = true;
} catch (\Exception $e) {
Log::warning('Notification failed', [
'error' => $e->getMessage(),
'payload' => $payload
]);
$payload['notification_sent'] = false;
}
return $next($payload);
}
private function sendNotification(array $payload): void
{
// Notification logic that might fail
}
}
<?php
namespace Tests\Unit\Flowpipe\Steps;
use Tests\TestCase;
use App\Flowpipe\Steps\ValidateUserInputStep;
class ValidateUserInputStepTest extends TestCase
{
public function test_validates_correct_input()
{
$step = new ValidateUserInputStep();
$payload = [
'name' => 'John Doe',
'email' => '[email protected]',
'password' => 'SecurePassword123!',
];
$result = $step->handle($payload, fn($data) => $data);
$this->assertEquals($payload, $result);
}
public function test_throws_exception_for_invalid_input()
{
$step = new ValidateUserInputStep();
$payload = [
'name' => '',
'email' => 'invalid-email',
];
$this->expectException(\InvalidArgumentException::class);
$step->handle($payload, fn($data) => $data);
}
}
<?php
namespace Tests\Feature\Flowpipe\Steps;
use Tests\TestCase;
use App\Flowpipe\Steps\CreateUserStep;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
class CreateUserStepTest extends TestCase
{
use RefreshDatabase;
public function test_creates_user_successfully()
{
$step = app(CreateUserStep::class);
$payload = [
'name' => 'John Doe',
'email' => '[email protected]',
'password' => 'SecurePassword123!',
];
$result = $step->handle($payload, fn($data) => $data);
$this->assertDatabaseHas('users', [
'name' => 'John Doe',
'email' => '[email protected]',
]);
$this->assertArrayHasKey('user_id', $result);
$this->assertInstanceOf(User::class, $result['user']);
}
}
// Good: Descriptive action-oriented names
ValidateUserInputStep
CreateUserAccountStep
SendWelcomeEmailStep
ProcessPaymentStep
UpdateInventoryStep
// Avoid: Generic or unclear names
Step1
UserStep
ProcessStep
app/Flowpipe/Steps/
βββ Auth/
β βββ ValidateCredentialsStep.php
β βββ GenerateTokenStep.php
β βββ RevokeTokenStep.php
βββ User/
β βββ CreateUserStep.php
β βββ UpdateProfileStep.php
β βββ DeactivateUserStep.php
βββ Payment/
β βββ ValidatePaymentMethodStep.php
β βββ ProcessChargeStep.php
β βββ RefundPaymentStep.php
βββ Notification/
βββ SendEmailStep.php
βββ SendSmsStep.php
βββ PushNotificationStep.php
Each step should do one thing well:
// Good: Focused responsibility
class ValidateEmailStep implements FlowStep
{
public function handle(mixed $payload, Closure $next): mixed
{
// Only validate email format and uniqueness
return $next($payload);
}
}
// Avoid: Multiple responsibilities
class ValidateAndCreateUserStep implements FlowStep
{
// Don't combine validation and creation
}
// Good: Create new array/object
$payload['new_field'] = $value;
$payload = array_merge($payload, $newData);
// Avoid: Modifying nested objects directly
$payload['user']->email = $newEmail; // Can cause issues
public function handle(mixed $payload, Closure $next): mixed
{
$user = $this->createUser($payload);
// Add meaningful data for subsequent steps
$payload['user'] = $user;
$payload['user_id'] = $user->id;
$payload['created_at'] = $user->created_at;
return $next($payload);
}
public function handle(mixed $payload, Closure $next): mixed
{
Log::info('Processing payment', [
'amount' => $payload['amount'],
'user_id' => $payload['user_id']
]);
$result = $this->processPayment($payload);
Log::info('Payment processed', [
'transaction_id' => $result['transaction_id'],
'status' => $result['status']
]);
return $next($payload);
}
- Step Groups - Organize and reuse workflow components
- Error Handling - Advanced error handling strategies
- Queue Integration - Asynchronous workflow processing
- Example User Registration - See steps in action
Laravel Flowpipe - YAML-driven workflow engine for Laravel
GitHub: Laravel Flowpipe Repository | Support: GitHub Issues
Quick Navigation: Home β’ Installation β’ Configuration β’ Commands β’ Examples
π§ Developed by Grazulex