-
-
Notifications
You must be signed in to change notification settings - Fork 0
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 are conditions that must be met before a state transition can occur. They act as gatekeepers, ensuring business rules are enforced.
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;
}
}
<?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;
}
}
<?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;
}
}
<?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;
}
}
<?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();
}
}
<?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
);
}
}
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 define what happens during a state transition. They handle side effects like sending emails, updating records, or calling external services.
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'
]);
}
}
<?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()
]);
}
}
<?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()
]);
}
}
<?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()
]);
}
}
<?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()
]
]);
}
}
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()
]);
}
}
transitions:
- name: approve
from: pending
to: approved
guard: IsManager
action: SendApprovalEmail
transitions:
- name: auto_approve
from: pending
to: approved
guard:
and:
- HasMinimumAmount
- IsRegularCustomer
- not: IsBlacklisted
- or:
- IsVIPCustomer
- HasManagerApproval
transitions:
- name: ship
from: processing
to: shipped
guard: HasInventory
action:
- ReserveInventory
- CreateShipment
- SendTrackingEmail
- UpdateMetrics
Use model methods directly:
transitions:
- name: validate
from: draft
to: pending
guard: isValid # Calls $model->isValid()
action: sendForReview # Calls $model->sendForReview()
<?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'));
}
}
<?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);
}
}
<?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}");
}
}
}
<?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()
]);
}
}
- 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
- 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
<?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
}
}
}
Now that you understand Guards & Actions:
- Learn about Events - Handle state change notifications
- Explore State History - Track and audit changes
- See Real Examples - Complete workflow implementations
- Test Your Logic - Ensure guards and actions work correctly
Need inspiration? Check out our examples collection for real-world guard and action implementations.
π― Laravel Statecraft - Advanced State Machine Implementation for Laravel
Navigate: π Home | π¦ Installation | π Basic Guide | π YAML Config | π‘ Examples
Resources: π‘οΈ Guards & Actions | π― Events | π State History | π§ͺ Testing | π¨ Commands
π Community Resources:
Made with β€οΈ for the Laravel community β’ Contribute β’ License