Skip to content

PHP Steps

Jean-Marc Strauven edited this page Aug 6, 2025 · 1 revision

πŸ—οΈ PHP Steps

← 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.

🧠 Core Concept

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);
    }
}

πŸ“‹ Step Interface

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;
}

πŸ”§ Basic Step Implementation

Simple Data Transformation

<?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);
    }
}

Validation Step

<?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());
    }
}

πŸŽ›οΈ Advanced Step Features

Dependency Injection

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);
    }
}

Configurable Steps

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

Conditional Processing

<?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);
    }
}

πŸ”„ Data Flow Patterns

Data Enrichment

<?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);
    }
}

Data Transformation

<?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);
    }
}

Side Effects (Logging, Notifications)

<?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);
    }
}

🚨 Error Handling in Steps

Throwing Exceptions

<?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;
    }
}

Graceful Error Handling

<?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
    }
}

πŸ§ͺ Testing Steps

Unit Testing

<?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);
    }
}

Integration Testing

<?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']);
    }
}

🏷️ Step Naming Conventions

Class Names

// Good: Descriptive action-oriented names
ValidateUserInputStep
CreateUserAccountStep
SendWelcomeEmailStep
ProcessPaymentStep
UpdateInventoryStep

// Avoid: Generic or unclear names
Step1
UserStep
ProcessStep

File Organization

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

πŸ’‘ Best Practices

1. Single Responsibility

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
}

2. Immutable Payload Modification

// 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

3. Meaningful Return Values

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);
}

4. Logging and Observability

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);
}

🎯 What's Next?

πŸš€ Laravel Flowpipe

🏠 Home

🏁 Getting Started

πŸ“š Core Concepts

πŸš€ Advanced Features

πŸ› οΈ Tools & Configuration

πŸ“– Examples


πŸ”— GitHub Repository

Clone this wiki locally