Skip to content

Guards & Actions

Jean-Marc Strauven edited this page Aug 6, 2025 · 2 revisions

πŸ›‘οΈ Guards & Actions

Guards and Actions are the core business logic components of Laravel Statecraft. Guards control when transitions can occur, while Actions define what happens during transitions.

πŸ›‘οΈ Guards

Guards are conditions that must be met before a state transition can occur. They act as gatekeepers, ensuring business rules are enforced.

Creating a Guard

Guards implement the Guard contract:

<?php

namespace App\StateMachine\Guards;

use Grazulex\LaravelStatecraft\Contracts\Guard;
use Illuminate\Database\Eloquent\Model;

class PaymentGuard implements Guard
{
    public function check(Model $model, string $from, string $to): bool
    {
        // Your guard logic here
        return $model->amount > 0 && $model->payment_method !== null;
    }
}

Guard Examples

Permission-based Guard

<?php

namespace App\StateMachine\Guards;

use Grazulex\LaravelStatecraft\Contracts\Guard;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;

class IsManager implements Guard
{
    public function check(Model $model, string $from, string $to): bool
    {
        $user = Auth::user();
        
        if (!$user) {
            return false;
        }
        
        return $user->hasRole('manager') || $user->is_manager;
    }
}

Business Logic Guard

<?php

namespace App\StateMachine\Guards;

use Grazulex\LaravelStatecraft\Contracts\Guard;
use Illuminate\Database\Eloquent\Model;

class HasMinimumAmount implements Guard
{
    public function check(Model $model, string $from, string $to): bool
    {
        // Orders must be at least $10 to be processed
        return $model->amount >= 10.00;
    }
}

Inventory Guard

<?php

namespace App\StateMachine\Guards;

use Grazulex\LaravelStatecraft\Contracts\Guard;
use Illuminate\Database\Eloquent\Model;

class HasInventory implements Guard
{
    public function check(Model $model, string $from, string $to): bool
    {
        // Check if all order items are in stock
        foreach ($model->items as $item) {
            if ($item->product->stock < $item->quantity) {
                return false;
            }
        }
        
        return true;
    }
}

Time-based Guard

<?php

namespace App\StateMachine\Guards;

use Grazulex\LaravelStatecraft\Contracts\Guard;
use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;

class IsWithinBusinessHours implements Guard
{
    public function check(Model $model, string $from, string $to): bool
    {
        $now = Carbon::now();
        
        // Only allow processing during business hours (9 AM - 5 PM)
        return $now->hour >= 9 && $now->hour < 17 && $now->isWeekday();
    }
}

External Service Guard

<?php

namespace App\StateMachine\Guards;

use Grazulex\LaravelStatecraft\Contracts\Guard;
use Illuminate\Database\Eloquent\Model;
use App\Services\PaymentGateway;

class PaymentValidationGuard implements Guard
{
    public function __construct(
        private PaymentGateway $paymentGateway
    ) {}

    public function check(Model $model, string $from, string $to): bool
    {
        // Validate payment with external gateway
        return $this->paymentGateway->validatePayment(
            $model->payment_token,
            $model->amount
        );
    }
}

Guard with Context

Guards can access additional context passed during transitions:

<?php

namespace App\StateMachine\Guards;

use Grazulex\LaravelStatecraft\Contracts\GuardWithContext;
use Illuminate\Database\Eloquent\Model;

class PaymentMethodGuard implements GuardWithContext
{
    public function check(Model $model, string $from, string $to, array $context = []): bool
    {
        $allowedMethods = ['credit_card', 'paypal', 'bank_transfer'];
        $paymentMethod = $context['payment_method'] ?? $model->payment_method;
        
        return in_array($paymentMethod, $allowedMethods);
    }
}

Usage with context:

$order->transitionTo('pay', [
    'payment_method' => 'credit_card',
    'user_id' => auth()->id()
]);

βš™οΈ Actions

Actions define what happens during a state transition. They handle side effects like sending emails, updating records, or calling external services.

Creating an Action

Actions implement the Action contract:

<?php

namespace App\StateMachine\Actions;

use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;

class ProcessPayment implements Action
{
    public function execute(Model $model, string $from, string $to): void
    {
        // Your action logic here
        $model->update([
            'paid_at' => now(),
            'payment_status' => 'completed'
        ]);
    }
}

Action Examples

Email Notification Action

<?php

namespace App\StateMachine\Actions;

use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderConfirmationMail;

class SendConfirmationEmail implements Action
{
    public function execute(Model $model, string $from, string $to): void
    {
        Mail::to($model->customer_email)->send(
            new OrderConfirmationMail($model)
        );
        
        // Log the email sent
        $model->update([
            'confirmation_email_sent_at' => now()
        ]);
    }
}

Inventory Management Action

<?php

namespace App\StateMachine\Actions;

use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;

class ReserveInventory implements Action
{
    public function execute(Model $model, string $from, string $to): void
    {
        foreach ($model->items as $item) {
            $product = $item->product;
            
            // Reserve inventory
            $product->decrement('available_stock', $item->quantity);
            $product->increment('reserved_stock', $item->quantity);
        }
        
        $model->update([
            'inventory_reserved_at' => now()
        ]);
    }
}

External Service Action

<?php

namespace App\StateMachine\Actions;

use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;
use App\Services\ShippingService;

class CreateShipment implements Action
{
    public function __construct(
        private ShippingService $shippingService
    ) {}

    public function execute(Model $model, string $from, string $to): void
    {
        $shipment = $this->shippingService->createShipment([
            'order_id' => $model->id,
            'address' => $model->shipping_address,
            'items' => $model->items->toArray()
        ]);
        
        $model->update([
            'tracking_number' => $shipment['tracking_number'],
            'shipped_at' => now()
        ]);
    }
}

Database Update Action

<?php

namespace App\StateMachine\Actions;

use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;

class UpdateOrderMetrics implements Action
{
    public function execute(Model $model, string $from, string $to): void
    {
        // Update related models
        $model->customer->increment('total_orders');
        $model->customer->increment('total_spent', $model->amount);
        
        // Create audit log
        $model->auditLogs()->create([
            'action' => 'state_changed',
            'from_state' => $from,
            'to_state' => $to,
            'user_id' => auth()->id(),
            'metadata' => [
                'timestamp' => now(),
                'ip_address' => request()->ip()
            ]
        ]);
    }
}

Action with Context

Actions can access additional context:

<?php

namespace App\StateMachine\Actions;

use Grazulex\LaravelStatecraft\Contracts\ActionWithContext;
use Illuminate\Database\Eloquent\Model;

class ProcessPaymentWithMethod implements ActionWithContext
{
    public function execute(Model $model, string $from, string $to, array $context = []): void
    {
        $paymentMethod = $context['payment_method'] ?? 'default';
        $userId = $context['user_id'] ?? null;
        
        // Process payment with specific method
        $result = PaymentProcessor::process($model, $paymentMethod);
        
        $model->update([
            'payment_method' => $paymentMethod,
            'payment_processor_id' => $result['transaction_id'],
            'processed_by' => $userId,
            'paid_at' => now()
        ]);
    }
}

πŸ”„ YAML Configuration

Simple Guards and Actions

transitions:
  - name: approve
    from: pending
    to: approved
    guard: IsManager
    action: SendApprovalEmail

Complex Guard Logic

transitions:
  - name: auto_approve
    from: pending
    to: approved
    guard:
      and:
        - HasMinimumAmount
        - IsRegularCustomer
        - not: IsBlacklisted
        - or:
            - IsVIPCustomer
            - HasManagerApproval

Multiple Actions

transitions:
  - name: ship
    from: processing
    to: shipped
    guard: HasInventory
    action:
      - ReserveInventory
      - CreateShipment
      - SendTrackingEmail
      - UpdateMetrics

Method-based Guards and Actions

Use model methods directly:

transitions:
  - name: validate
    from: draft
    to: pending
    guard: isValid          # Calls $model->isValid()
    action: sendForReview   # Calls $model->sendForReview()

πŸ§ͺ Testing Guards and Actions

Testing Guards

<?php

namespace Tests\Unit\Guards;

use App\StateMachine\Guards\IsManager;
use App\Models\Order;
use App\Models\User;
use Tests\TestCase;

class IsManagerTest extends TestCase
{
    public function test_manager_can_approve_order()
    {
        $manager = User::factory()->manager()->create();
        $this->actingAs($manager);
        
        $order = Order::factory()->create();
        $guard = new IsManager();
        
        $this->assertTrue($guard->check($order, 'pending', 'approved'));
    }
    
    public function test_regular_user_cannot_approve_order()
    {
        $user = User::factory()->create();
        $this->actingAs($user);
        
        $order = Order::factory()->create();
        $guard = new IsManager();
        
        $this->assertFalse($guard->check($order, 'pending', 'approved'));
    }
}

Testing Actions

<?php

namespace Tests\Unit\Actions;

use App\StateMachine\Actions\SendConfirmationEmail;
use App\Models\Order;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

class SendConfirmationEmailTest extends TestCase
{
    public function test_sends_confirmation_email()
    {
        Mail::fake();
        
        $order = Order::factory()->create([
            'customer_email' => '[email protected]'
        ]);
        
        $action = new SendConfirmationEmail();
        $action->execute($order, 'pending', 'paid');
        
        Mail::assertSent(OrderConfirmationMail::class, function ($mail) use ($order) {
            return $mail->hasTo('[email protected]');
        });
        
        $this->assertNotNull($order->fresh()->confirmation_email_sent_at);
    }
}

πŸ”§ Advanced Patterns

Conditional Actions

<?php

namespace App\StateMachine\Actions;

use Grazulex\LaravelStatecraft\Contracts\ActionWithContext;
use Illuminate\Database\Eloquent\Model;

class ConditionalNotification implements ActionWithContext
{
    public function execute(Model $model, string $from, string $to, array $context = []): void
    {
        // Only send email if customer opted in
        if ($model->customer->email_notifications_enabled) {
            Mail::to($model->customer->email)->send(
                new OrderStatusChangedMail($model, $from, $to)
            );
        }
        
        // Always send SMS for urgent orders
        if ($model->is_urgent) {
            SMS::send($model->customer->phone, "Order #{$model->id} status: {$to}");
        }
    }
}

Rollback Actions

<?php

namespace App\StateMachine\Actions;

use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;

class RollbackInventory implements Action
{
    public function execute(Model $model, string $from, string $to): void
    {
        // Return reserved inventory to available stock
        foreach ($model->items as $item) {
            $product = $item->product;
            $product->increment('available_stock', $item->quantity);
            $product->decrement('reserved_stock', $item->quantity);
        }
        
        $model->update([
            'inventory_released_at' => now()
        ]);
    }
}

πŸ“‹ Best Practices

Guard Best Practices

  • Keep guards simple and focused on a single condition
  • Use descriptive class names (e.g., HasValidPayment, IsWithinBusinessHours)
  • Handle edge cases (null values, missing data)
  • Make guards stateless when possible
  • Use dependency injection for external services

Action Best Practices

  • Make actions idempotent when possible
  • Handle failures gracefully
  • Log important actions for auditing
  • Keep actions focused on a single responsibility
  • Use queues for time-consuming operations

Error Handling

<?php

namespace App\StateMachine\Actions;

use Grazulex\LaravelStatecraft\Contracts\Action;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;

class RobustPaymentAction implements Action
{
    public function execute(Model $model, string $from, string $to): void
    {
        try {
            $result = PaymentGateway::charge($model->amount, $model->payment_token);
            
            $model->update([
                'payment_id' => $result['id'],
                'paid_at' => now()
            ]);
            
        } catch (\Exception $e) {
            Log::error("Payment failed for order {$model->id}: " . $e->getMessage());
            
            // Mark payment as failed but don't throw exception
            // Let the state machine handle the transition
            $model->update([
                'payment_error' => $e->getMessage(),
                'payment_attempted_at' => now()
            ]);
            
            throw $e; // Re-throw to prevent state transition
        }
    }
}

πŸš€ What's Next?

Now that you understand Guards & Actions:

  1. Learn about Events - Handle state change notifications
  2. Explore State History - Track and audit changes
  3. See Real Examples - Complete workflow implementations
  4. Test Your Logic - Ensure guards and actions work correctly

Need inspiration? Check out our examples collection for real-world guard and action implementations.

Clone this wiki locally