RestrictionResult
The RestrictionResult class represents the outcome of a restriction task evaluation. It indicates whether a workflow transition is allowed to proceed and provides machine-readable codes and human-readable messages to explain the decision.
Namespace
JobMetric\Flow\Support\RestrictionResult
Overview
RestrictionResult encapsulates:
- Allowed Flag: Boolean indicating if the operation is allowed
- Code: Machine-readable code identifying the reason
- Message: Human-readable explanation for UI or logging
Static Factory Methods
allow()
Create a result that allows the operation:
use JobMetric\Flow\Support\RestrictionResult;
$result = RestrictionResult::allow();
Returns: RestrictionResult with allowed=true, code=null, message=null
deny()
Create a result that denies the operation:
// With code only
$result = RestrictionResult::deny('PERMISSION_DENIED');
// With code and message
$result = RestrictionResult::deny(
'ORDER_SHIPPED',
'Cannot cancel shipped orders'
);
Parameters:
string $code: Machine-readable code (required)?string $message = null: Human-readable message (optional)
Returns: RestrictionResult with allowed=false
Query Methods
allowed()
Determine if the operation is allowed:
$result = RestrictionResult::allow();
$result->allowed(); // true
$result = RestrictionResult::deny('ERROR_CODE');
$result->allowed(); // false
Returns: bool
code()
Get the machine-readable code:
$result = RestrictionResult::deny('PERMISSION_DENIED', 'Message');
$result->code(); // 'PERMISSION_DENIED'
$result = RestrictionResult::allow();
$result->code(); // null
Returns: ?string
message()
Get the human-readable message:
$result = RestrictionResult::deny('ERROR', 'Error message');
$result->message(); // 'Error message'
$result = RestrictionResult::allow();
$result->message(); // null
Returns: ?string
Understanding RestrictionResult in Workflow Execution
How RestrictionResult Works
When a transition is executed, restriction tasks are evaluated first:
// Inside FlowTransition::runner()
// 1. Execute all restriction tasks
foreach ($restrictionTasks as $task) {
$result = $task->restriction($context);
if (!$result->allowed()) {
// Transition is DENIED - stop immediately
throw new TaskRestrictionException(
$result->code(),
$result->message()
);
}
}
// 2. If all restrictions pass, continue with validation tasks
// 3. Then execute action tasks
Key Points:
- Restriction tasks run before validation and action tasks
- If any restriction denies, the transition stops immediately
- The code and message are used for error handling and user feedback
- Multiple restriction tasks can exist - all must allow
Complete Examples
Example 1: Basic Allow - Simple Pass-Through
A simple restriction that always allows (useful for testing or optional restrictions):
namespace App\Flows\Order;
use JobMetric\Flow\Contracts\AbstractRestrictionTask;
use JobMetric\Flow\Support\FlowTaskContext;
use JobMetric\Flow\Support\FlowTaskDefinition;
use JobMetric\Flow\Support\RestrictionResult;
use JobMetric\Form\FormBuilder;
class BasicAllowRestrictionTask extends AbstractRestrictionTask
{
public static function subject(): string
{
return \App\Models\Order::class;
}
public static function definition(): FlowTaskDefinition
{
return new FlowTaskDefinition(
title: 'flow::base.task.basic_allow.title',
description: 'Always allows the transition',
);
}
public function form(): FormBuilder
{
return new FormBuilder;
}
public function restriction(FlowTaskContext $context): RestrictionResult
{
// Always allow - no restrictions
return RestrictionResult::allow();
}
}
Use Cases:
- Testing scenarios
- Optional restrictions that can be enabled/disabled
- Placeholder restrictions
Example 2: Advanced Permission Check - Multi-Level Authorization
A comprehensive permission check that handles multiple authorization scenarios:
namespace App\Flows\Order;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use JobMetric\Flow\Contracts\AbstractRestrictionTask;
use JobMetric\Flow\Support\FlowTaskContext;
use JobMetric\Flow\Support\FlowTaskDefinition;
use JobMetric\Flow\Support\RestrictionResult;
use JobMetric\Form\FormBuilder;
class PermissionRestrictionTask extends AbstractRestrictionTask
{
public static function subject(): string
{
return \App\Models\Order::class;
}
public static function definition(): FlowTaskDefinition
{
return new FlowTaskDefinition(
title: 'flow::base.task.permission_check.title',
description: 'Checks user permissions before allowing transition',
);
}
public function form(): FormBuilder
{
return (new FormBuilder)
->hiddenCustomField('require_authentication', 'boolean', true, 'Require user authentication')
->hiddenCustomField('allowed_roles', 'array', ['admin', 'manager'], 'Allowed roles')
->hiddenCustomField('check_ownership', 'boolean', true, 'Check resource ownership')
->hiddenCustomField('require_specific_permission', 'string', 'cancel', 'Required permission');
}
public function restriction(FlowTaskContext $context): RestrictionResult
{
$order = $context->subject();
$user = $context->user();
$config = $context->config();
// Extract configuration
$requireAuth = $config['require_authentication'] ?? true;
$allowedRoles = $config['allowed_roles'] ?? [];
$checkOwnership = $config['check_ownership'] ?? true;
$requiredPermission = $config['require_specific_permission'] ?? 'cancel';
// 1. Check authentication
if ($requireAuth && !$user) {
return RestrictionResult::deny(
'USER_NOT_AUTHENTICATED',
'You must be logged in to perform this action'
);
}
// 2. Check role-based access
if (!empty($allowedRoles)) {
$userRoles = $user?->roles->pluck('name')->toArray() ?? [];
$hasAllowedRole = !empty(array_intersect($userRoles, $allowedRoles));
if (!$hasAllowedRole) {
return RestrictionResult::deny(
'INSUFFICIENT_ROLE',
'You do not have the required role to perform this action'
);
}
}
// 3. Check policy/permission
if ($requiredPermission && $user) {
if (!Gate::allows($requiredPermission, $order)) {
return RestrictionResult::deny(
'PERMISSION_DENIED',
"You do not have permission to {$requiredPermission} this order"
);
}
}
// 4. Check ownership (if enabled)
if ($checkOwnership && $user) {
// Admin can access any order
if (!$user->hasRole('admin')) {
if ($order->user_id !== $user->id) {
return RestrictionResult::deny(
'OWNERSHIP_MISMATCH',
'You can only perform this action on your own orders'
);
}
}
}
// 5. Check account status
if ($user && $user->account_status === 'suspended') {
return RestrictionResult::deny(
'ACCOUNT_SUSPENDED',
'Your account is suspended. Please contact support.'
);
}
// All permission checks passed
return RestrictionResult::allow();
}
}
Key Points:
- Multiple permission checks in sequence
- Configurable permission requirements
- Role-based and policy-based authorization
- Ownership verification
- Account status checks
Example 3: Comprehensive Business Rule Check - Order State Machine
A complete business rule check that validates order state transitions:
namespace App\Flows\Order;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use JobMetric\Flow\Contracts\AbstractRestrictionTask;
use JobMetric\Flow\Support\FlowTaskContext;
use JobMetric\Flow\Support\FlowTaskDefinition;
use JobMetric\Flow\Support\RestrictionResult;
use JobMetric\Form\FormBuilder;
class OrderStateRestrictionTask extends AbstractRestrictionTask
{
public static function subject(): string
{
return \App\Models\Order::class;
}
public static function definition(): FlowTaskDefinition
{
return new FlowTaskDefinition(
title: 'flow::base.task.order_state_check.title',
description: 'Validates order state transitions',
);
}
public function form(): FormBuilder
{
return (new FormBuilder)
->hiddenCustomField('allowed_from_statuses', 'array', ['pending', 'processing'], 'Allowed source statuses')
->hiddenCustomField('blocked_statuses', 'array', ['cancelled', 'refunded'], 'Blocked statuses')
->hiddenCustomField('check_payment_status', 'boolean', true, 'Check payment status')
->hiddenCustomField('check_inventory', 'boolean', true, 'Check inventory availability');
}
public function restriction(FlowTaskContext $context): RestrictionResult
{
$order = $context->subject();
$payload = $context->payload();
$config = $context->config();
// Extract configuration
$allowedFromStatuses = $config['allowed_from_statuses'] ?? [];
$blockedStatuses = $config['blocked_statuses'] ?? ['cancelled', 'refunded'];
$checkPayment = $config['check_payment_status'] ?? true;
$checkInventory = $config['check_inventory'] ?? true;
// Get target status from payload
$targetStatus = $payload['status'] ?? null;
// 1. Check if order is in a blocked state
if (in_array($order->status, $blockedStatuses)) {
return RestrictionResult::deny(
'ORDER_IN_BLOCKED_STATE',
"Cannot transition from '{$order->status}' status"
);
}
// 2. Check if current status allows transition
if (!empty($allowedFromStatuses) && !in_array($order->status, $allowedFromStatuses)) {
return RestrictionResult::deny(
'INVALID_SOURCE_STATUS',
"Cannot transition from '{$order->status}' status. Allowed statuses: " . implode(', ', $allowedFromStatuses)
);
}
// 3. Check state machine rules
$stateMachineRules = $this->getStateMachineRules();
if (isset($stateMachineRules[$order->status])) {
$allowedTransitions = $stateMachineRules[$order->status];
if ($targetStatus && !in_array($targetStatus, $allowedTransitions)) {
return RestrictionResult::deny(
'INVALID_STATE_TRANSITION',
"Cannot transition from '{$order->status}' to '{$targetStatus}'"
);
}
}
// 4. Check payment status
if ($checkPayment) {
if ($order->payment_status === 'refunded' && $targetStatus !== 'cancelled') {
return RestrictionResult::deny(
'PAYMENT_ALREADY_REFUNDED',
'Payment has already been refunded. Order must remain cancelled.'
);
}
if ($order->payment_status === 'pending' && in_array($targetStatus, ['shipped', 'delivered'])) {
return RestrictionResult::deny(
'PAYMENT_PENDING',
'Cannot ship or deliver order with pending payment'
);
}
}
// 5. Check inventory availability (for shipping)
if ($checkInventory && $targetStatus === 'shipped') {
foreach ($order->items as $item) {
if ($item->product->stock < $item->quantity) {
return RestrictionResult::deny(
'INSUFFICIENT_INVENTORY',
"Insufficient inventory for product: {$item->product->name}"
);
}
}
}
// 6. Check if order has active disputes
if ($this->hasActiveDispute($order)) {
return RestrictionResult::deny(
'ACTIVE_DISPUTE',
'Cannot modify order with active dispute'
);
}
// 7. Check if order is locked
if ($order->is_locked) {
return RestrictionResult::deny(
'ORDER_LOCKED',
'Order is currently locked and cannot be modified'
);
}
// All business rule checks passed
return RestrictionResult::allow();
}
protected function getStateMachineRules(): array
{
return [
'pending' => ['processing', 'cancelled'],
'processing' => ['shipped', 'cancelled'],
'shipped' => ['delivered', 'returned'],
'delivered' => ['returned', 'completed'],
'cancelled' => [], // Terminal state
'completed' => [], // Terminal state
];
}
protected function hasActiveDispute(Order $order): bool
{
return $order->disputes()
->where('status', 'open')
->exists();
}
}
Key Points:
- State machine validation
- Configurable business rules
- Multiple validation layers
- Terminal state protection
- Payment and inventory checks
Example 4: Enterprise-Level Multi-Condition Restriction
A comprehensive restriction that checks multiple business conditions with detailed error reporting:
namespace App\Flows\Order;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use JobMetric\Flow\Contracts\AbstractRestrictionTask;
use JobMetric\Flow\Support\FlowTaskContext;
use JobMetric\Flow\Support\FlowTaskDefinition;
use JobMetric\Flow\Support\RestrictionResult;
use JobMetric\Form\FormBuilder;
class EnterpriseOrderCancellationRestrictionTask extends AbstractRestrictionTask
{
public static function subject(): string
{
return \App\Models\Order::class;
}
public static function definition(): FlowTaskDefinition
{
return new FlowTaskDefinition(
title: 'flow::base.task.enterprise_cancellation_restriction.title',
description: 'Comprehensive order cancellation restrictions',
);
}
public function form(): FormBuilder
{
return (new FormBuilder)
->hiddenCustomField('max_cancellation_hours', 'integer', 24, 'Maximum hours after creation')
->hiddenCustomField('max_cancellation_amount', 'decimal', 1000, 'Maximum order amount for cancellation')
->hiddenCustomField('require_reason', 'boolean', true, 'Require cancellation reason')
->hiddenCustomField('check_rate_limit', 'boolean', true, 'Check user rate limit')
->hiddenCustomField('max_cancellations_per_month', 'integer', 5, 'Maximum cancellations per month')
->hiddenCustomField('allow_admin_override', 'boolean', true, 'Allow admin override');
}
public function restriction(FlowTaskContext $context): RestrictionResult
{
$order = $context->subject();
$user = $context->user();
$payload = $context->payload();
$config = $context->config();
// Extract configuration
$maxHours = $config['max_cancellation_hours'] ?? 24;
$maxAmount = $config['max_cancellation_amount'] ?? 1000;
$requireReason = $config['require_reason'] ?? true;
$checkRateLimit = $config['check_rate_limit'] ?? true;
$maxPerMonth = $config['max_cancellations_per_month'] ?? 5;
$allowAdminOverride = $config['allow_admin_override'] ?? true;
// Admin override check (early exit)
if ($allowAdminOverride && $user && $user->hasRole('admin')) {
return RestrictionResult::allow();
}
// 1. User authentication check
if (!$user) {
return RestrictionResult::deny(
'USER_NOT_AUTHENTICATED',
'You must be logged in to cancel orders'
);
}
// 2. Permission check
if (!$user->can('cancel', $order)) {
return RestrictionResult::deny(
'PERMISSION_DENIED',
'You do not have permission to cancel this order'
);
}
// 3. Ownership check
if ($order->user_id !== $user->id && !$user->hasRole('admin')) {
return RestrictionResult::deny(
'OWNERSHIP_MISMATCH',
'You can only cancel your own orders'
);
}
// 4. Order status check
$blockedStatuses = ['shipped', 'delivered', 'cancelled', 'refunded'];
if (in_array($order->status, $blockedStatuses)) {
return RestrictionResult::deny(
'ORDER_STATUS_INVALID',
"Cannot cancel order with status: {$order->status}"
);
}
// 5. Time limit check
$hoursSinceCreated = $order->created_at->diffInHours(now());
if ($hoursSinceCreated > $maxHours) {
return RestrictionResult::deny(
'TIME_LIMIT_EXCEEDED',
"Orders can only be cancelled within {$maxHours} hours of creation. " .
"Order was created {$hoursSinceCreated} hours ago."
);
}
// 6. Amount limit check
if ($order->total > $maxAmount) {
return RestrictionResult::deny(
'AMOUNT_EXCEEDED',
"Orders over {$maxAmount} cannot be cancelled automatically. Please contact support."
);
}
// 7. Reason requirement check
if ($requireReason && empty($payload['reason'])) {
return RestrictionResult::deny(
'REASON_REQUIRED',
'Cancellation reason is required'
);
}
if ($requireReason && strlen($payload['reason'] ?? '') < 10) {
return RestrictionResult::deny(
'REASON_TOO_SHORT',
'Cancellation reason must be at least 10 characters'
);
}
// 8. Rate limiting check
if ($checkRateLimit) {
$cancellationCount = $this->getRecentCancellationCount($user);
if ($cancellationCount >= $maxPerMonth) {
return RestrictionResult::deny(
'RATE_LIMIT_EXCEEDED',
"You have exceeded the maximum of {$maxPerMonth} cancellations per month. Please contact support."
);
}
}
// 9. Payment status check
if ($order->payment_status === 'refunded') {
return RestrictionResult::deny(
'PAYMENT_ALREADY_REFUNDED',
'Payment has already been refunded. Order cannot be cancelled again.'
);
}
// 10. Active dispute check
if ($this->hasActiveDispute($order)) {
return RestrictionResult::deny(
'ACTIVE_DISPUTE',
'Cannot cancel order with active dispute. Please resolve the dispute first.'
);
}
// 11. Inventory restock check
$restockInventory = $payload['restock_inventory'] ?? true;
if ($restockInventory && !$this->canRestockInventory($order)) {
return RestrictionResult::deny(
'INVENTORY_RESTOCK_FAILED',
'Cannot cancel order: some items cannot be restocked to inventory'
);
}
// 12. Shipping status check
if ($order->shipping_status === 'in_transit') {
return RestrictionResult::deny(
'ORDER_IN_TRANSIT',
'Cannot cancel order that is already in transit. Please contact shipping department.'
);
}
// 13. Subscription order check
if ($order->is_subscription && $order->subscription->is_active) {
return RestrictionResult::deny(
'ACTIVE_SUBSCRIPTION',
'Cannot cancel order with active subscription. Please cancel the subscription first.'
);
}
// All checks passed
return RestrictionResult::allow();
}
protected function getRecentCancellationCount(User $user): int
{
$cacheKey = "user_cancellations_{$user->id}_" . now()->format('Y-m');
return Cache::remember($cacheKey, 3600, function () use ($user) {
return $user->orders()
->where('status', 'cancelled')
->where('cancelled_at', '>=', now()->startOfMonth())
->count();
});
}
protected function hasActiveDispute(Order $order): bool
{
return $order->disputes()
->where('status', 'open')
->exists();
}
protected function canRestockInventory(Order $order): bool
{
foreach ($order->items as $item) {
if (!$item->product->canRestock()) {
return false;
}
}
return true;
}
}
Key Points:
- Multiple validation layers
- Early exit for admin override
- Detailed error messages with context
- Rate limiting with caching
- Complex business logic
- Configurable restrictions
Example 5: Advanced Configurable Restrictions - Dynamic Business Rules
A sophisticated restriction system that uses configuration for flexible business rules:
namespace App\Flows\Order;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
use JobMetric\Flow\Contracts\AbstractRestrictionTask;
use JobMetric\Flow\Support\FlowTaskContext;
use JobMetric\Flow\Support\FlowTaskDefinition;
use JobMetric\Flow\Support\RestrictionResult;
use JobMetric\Form\FormBuilder;
class ConfigurableBusinessRulesRestrictionTask extends AbstractRestrictionTask
{
public static function subject(): string
{
return \App\Models\Order::class;
}
public static function definition(): FlowTaskDefinition
{
return new FlowTaskDefinition(
title: 'flow::base.task.configurable_rules.title',
description: 'Configurable business rules restriction',
);
}
public function form(): FormBuilder
{
return (new FormBuilder)
->hiddenCustomField('allowed_statuses', 'array', ['pending', 'processing'], 'Allowed source statuses')
->hiddenCustomField('blocked_statuses', 'array', ['cancelled'], 'Blocked statuses')
->hiddenCustomField('allowed_user_roles', 'array', ['customer', 'admin'], 'Allowed user roles')
->hiddenCustomField('min_order_amount', 'decimal', 0, 'Minimum order amount')
->hiddenCustomField('max_order_amount', 'decimal', 10000, 'Maximum order amount')
->hiddenCustomField('allowed_payment_methods', 'array', ['credit_card', 'paypal'], 'Allowed payment methods')
->hiddenCustomField('require_verification', 'boolean', false, 'Require email verification')
->hiddenCustomField('business_hours_only', 'boolean', false, 'Only allow during business hours')
->hiddenCustomField('business_hours_start', 'integer', 9, 'Business hours start')
->hiddenCustomField('business_hours_end', 'integer', 17, 'Business hours end');
}
public function restriction(FlowTaskContext $context): RestrictionResult
{
$order = $context->subject();
$user = $context->user();
$payload = $context->payload();
$config = $context->config();
// Extract all configuration
$allowedStatuses = $config['allowed_statuses'] ?? [];
$blockedStatuses = $config['blocked_statuses'] ?? [];
$allowedRoles = $config['allowed_user_roles'] ?? [];
$minAmount = $config['min_order_amount'] ?? 0;
$maxAmount = $config['max_order_amount'] ?? 10000;
$allowedPaymentMethods = $config['allowed_payment_methods'] ?? [];
$requireVerification = $config['require_verification'] ?? false;
$businessHoursOnly = $config['business_hours_only'] ?? false;
$businessHoursStart = $config['business_hours_start'] ?? 9;
$businessHoursEnd = $config['business_hours_end'] ?? 17;
// 1. Status validation
if (!empty($allowedStatuses) && !in_array($order->status, $allowedStatuses)) {
return RestrictionResult::deny(
'STATUS_NOT_ALLOWED',
"Order status '{$order->status}' is not allowed. Allowed statuses: " . implode(', ', $allowedStatuses)
);
}
if (!empty($blockedStatuses) && in_array($order->status, $blockedStatuses)) {
return RestrictionResult::deny(
'STATUS_BLOCKED',
"Order status '{$order->status}' is blocked for this transition"
);
}
// 2. User role validation
if (!empty($allowedRoles) && $user) {
$userRoles = $user->roles->pluck('name')->toArray();
$hasAllowedRole = !empty(array_intersect($userRoles, $allowedRoles));
if (!$hasAllowedRole) {
return RestrictionResult::deny(
'ROLE_NOT_ALLOWED',
'Your role is not allowed for this transition'
);
}
}
// 3. Amount validation
if ($order->total < $minAmount) {
return RestrictionResult::deny(
'AMOUNT_TOO_LOW',
"Order amount {$order->total} is below minimum {$minAmount}"
);
}
if ($order->total > $maxAmount) {
return RestrictionResult::deny(
'AMOUNT_TOO_HIGH',
"Order amount {$order->total} exceeds maximum {$maxAmount}"
);
}
// 4. Payment method validation
if (!empty($allowedPaymentMethods) && !in_array($order->payment_method, $allowedPaymentMethods)) {
return RestrictionResult::deny(
'PAYMENT_METHOD_NOT_ALLOWED',
"Payment method '{$order->payment_method}' is not allowed"
);
}
// 5. Email verification check
if ($requireVerification && $user && !$user->email_verified_at) {
return RestrictionResult::deny(
'EMAIL_NOT_VERIFIED',
'Email verification is required for this transition'
);
}
// 6. Business hours check
if ($businessHoursOnly) {
$currentHour = now()->hour;
if ($currentHour < $businessHoursStart || $currentHour >= $businessHoursEnd) {
return RestrictionResult::deny(
'OUTSIDE_BUSINESS_HOURS',
"This operation is only available during business hours ({$businessHoursStart}:00 - {$businessHoursEnd}:00)"
);
}
}
// All configurable checks passed
return RestrictionResult::allow();
}
}
Key Points:
- Fully configurable restrictions
- Multiple validation layers from config
- Flexible business rules
- Easy to adjust without code changes
Example 6: Advanced Time-Based Restrictions - Scheduling and Windows
A comprehensive time-based restriction system with multiple time windows and scheduling:
namespace App\Flows\Order;
use App\Models\Order;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use JobMetric\Flow\Contracts\AbstractRestrictionTask;
use JobMetric\Flow\Support\FlowTaskContext;
use JobMetric\Flow\Support\FlowTaskDefinition;
use JobMetric\Flow\Support\RestrictionResult;
use JobMetric\Form\FormBuilder;
class TimeBasedRestrictionTask extends AbstractRestrictionTask
{
public static function subject(): string
{
return \App\Models\Order::class;
}
public static function definition(): FlowTaskDefinition
{
return new FlowTaskDefinition(
title: 'flow::base.task.time_based_restriction.title',
description: 'Time-based restrictions for transitions',
);
}
public function form(): FormBuilder
{
return (new FormBuilder)
->hiddenCustomField('business_hours_start', 'integer', 9, 'Business hours start (24h format)')
->hiddenCustomField('business_hours_end', 'integer', 17, 'Business hours end (24h format)')
->hiddenCustomField('allowed_days', 'array', ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], 'Allowed days of week')
->hiddenCustomField('blocked_dates', 'array', [], 'Blocked dates (YYYY-MM-DD)')
->hiddenCustomField('time_window_start', 'string', null, 'Time window start (HH:MM)')
->hiddenCustomField('time_window_end', 'string', null, 'Time window end (HH:MM)')
->hiddenCustomField('max_operations_per_hour', 'integer', 10, 'Maximum operations per hour')
->hiddenCustomField('timezone', 'string', 'UTC', 'Timezone for time checks');
}
public function restriction(FlowTaskContext $context): RestrictionResult
{
$order = $context->subject();
$user = $context->user();
$config = $context->config();
// Extract configuration
$businessHoursStart = $config['business_hours_start'] ?? 9;
$businessHoursEnd = $config['business_hours_end'] ?? 17;
$allowedDays = $config['allowed_days'] ?? [];
$blockedDates = $config['blocked_dates'] ?? [];
$timeWindowStart = $config['time_window_start'] ?? null;
$timeWindowEnd = $config['time_window_end'] ?? null;
$maxPerHour = $config['max_operations_per_hour'] ?? 10;
$timezone = $config['timezone'] ?? 'UTC';
$now = Carbon::now($timezone);
// 1. Business hours check
$currentHour = $now->hour;
if ($currentHour < $businessHoursStart || $currentHour >= $businessHoursEnd) {
return RestrictionResult::deny(
'OUTSIDE_BUSINESS_HOURS',
"This operation is only available during business hours ({$businessHoursStart}:00 - {$businessHoursEnd}:00 {$timezone})"
);
}
// 2. Day of week check
if (!empty($allowedDays)) {
$currentDay = strtolower($now->format('l'));
if (!in_array($currentDay, $allowedDays)) {
return RestrictionResult::deny(
'DAY_NOT_ALLOWED',
"This operation is not available on {$currentDay}. Allowed days: " . implode(', ', $allowedDays)
);
}
}
// 3. Blocked dates check
if (!empty($blockedDates)) {
$currentDate = $now->format('Y-m-d');
if (in_array($currentDate, $blockedDates)) {
return RestrictionResult::deny(
'DATE_BLOCKED',
"This operation is blocked on {$currentDate}"
);
}
}
// 4. Time window check
if ($timeWindowStart && $timeWindowEnd) {
$windowStart = Carbon::parse($timeWindowStart, $timezone);
$windowEnd = Carbon::parse($timeWindowEnd, $timezone);
$currentTime = $now->copy()->setDate($windowStart->year, $windowStart->month, $windowStart->day);
if ($currentTime->lt($windowStart) || $currentTime->gt($windowEnd)) {
return RestrictionResult::deny(
'OUTSIDE_TIME_WINDOW',
"This operation is only available between {$timeWindowStart} and {$timeWindowEnd} {$timezone}"
);
}
}
// 5. Rate limiting per hour
if ($maxPerHour > 0 && $user) {
$rateLimitKey = "operation_rate_limit_{$user->id}_" . $now->format('Y-m-d-H');
$currentCount = Cache::get($rateLimitKey, 0);
if ($currentCount >= $maxPerHour) {
return RestrictionResult::deny(
'RATE_LIMIT_EXCEEDED',
"You have exceeded the maximum of {$maxPerHour} operations per hour. Please try again later."
);
}
// Increment counter
Cache::put($rateLimitKey, $currentCount + 1, 3600);
}
// 6. Order age check (time since creation)
$hoursSinceCreation = $order->created_at->diffInHours($now);
if ($hoursSinceCreation > 48) {
return RestrictionResult::deny(
'ORDER_TOO_OLD',
"This operation is not available for orders older than 48 hours. Order was created {$hoursSinceCreation} hours ago."
);
}
// All time-based checks passed
return RestrictionResult::allow();
}
}
Key Points:
- Multiple time-based validations
- Business hours and days
- Time windows
- Rate limiting with caching
- Timezone support
- Order age restrictions
Example 7: Resource Locking and Concurrency Control
A restriction that prevents concurrent modifications and handles resource locking:
namespace App\Flows\Order;
use App\Models\Order;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use JobMetric\Flow\Contracts\AbstractRestrictionTask;
use JobMetric\Flow\Support\FlowTaskContext;
use JobMetric\Flow\Support\FlowTaskDefinition;
use JobMetric\Flow\Support\RestrictionResult;
use JobMetric\Form\FormBuilder;
class ResourceLockingRestrictionTask extends AbstractRestrictionTask
{
public static function subject(): string
{
return \App\Models\Order::class;
}
public static function definition(): FlowTaskDefinition
{
return new FlowTaskDefinition(
title: 'flow::base.task.resource_locking.title',
description: 'Prevents concurrent modifications',
);
}
public function form(): FormBuilder
{
return (new FormBuilder)
->hiddenCustomField('lock_timeout', 'integer', 300, 'Lock timeout in seconds')
->hiddenCustomField('check_concurrent_edits', 'boolean', true, 'Check for concurrent edits')
->hiddenCustomField('require_lock', 'boolean', true, 'Require resource lock');
}
public function restriction(FlowTaskContext $context): RestrictionResult
{
$order = $context->subject();
$user = $context->user();
$config = $context->config();
$lockTimeout = $config['lock_timeout'] ?? 300;
$checkConcurrent = $config['check_concurrent_edits'] ?? true;
$requireLock = $config['require_lock'] ?? true;
// 1. Check if resource is locked
$lockKey = "order_lock_{$order->id}";
$lockedBy = Cache::get($lockKey);
if ($lockedBy && $lockedBy !== $user?->id) {
$lockedUser = \App\Models\User::find($lockedBy);
return RestrictionResult::deny(
'RESOURCE_LOCKED',
"Order is currently being edited by {$lockedUser?->name}. Please try again later."
);
}
// 2. Check for concurrent edits (optimistic locking)
if ($checkConcurrent) {
$lastModified = $order->updated_at;
$payloadVersion = $context->payload()['version'] ?? null;
if ($payloadVersion && $order->version !== $payloadVersion) {
return RestrictionResult::deny(
'CONCURRENT_MODIFICATION',
'Order has been modified by another user. Please refresh and try again.'
);
}
}
// 3. Acquire lock if required
if ($requireLock && $user) {
$lockAcquired = Cache::add($lockKey, $user->id, $lockTimeout);
if (!$lockAcquired) {
return RestrictionResult::deny(
'LOCK_ACQUISITION_FAILED',
'Could not acquire lock on resource. Please try again.'
);
}
}
// 4. Check if order is in a transaction
if (DB::transactionLevel() > 0) {
// Order is already in a transaction, might be locked
return RestrictionResult::deny(
'RESOURCE_IN_TRANSACTION',
'Order is currently being processed in another transaction'
);
}
// All locking checks passed
return RestrictionResult::allow();
}
}
Example 8: Quota and Limit Restrictions
A restriction that enforces quotas and usage limits:
namespace App\Flows\Order;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use JobMetric\Flow\Contracts\AbstractRestrictionTask;
use JobMetric\Flow\Support\FlowTaskContext;
use JobMetric\Flow\Support\FlowTaskDefinition;
use JobMetric\Flow\Support\RestrictionResult;
use JobMetric\Form\FormBuilder;
class QuotaRestrictionTask extends AbstractRestrictionTask
{
public static function subject(): string
{
return \App\Models\Order::class;
}
public static function definition(): FlowTaskDefinition
{
return new FlowTaskDefinition(
title: 'flow::base.task.quota_restriction.title',
description: 'Enforces quotas and usage limits',
);
}
public function form(): FormBuilder
{
return (new FormBuilder)
->hiddenCustomField('max_orders_per_day', 'integer', 10, 'Maximum orders per day')
->hiddenCustomField('max_orders_per_month', 'integer', 100, 'Maximum orders per month')
->hiddenCustomField('max_total_amount_per_day', 'decimal', 5000, 'Maximum total amount per day')
->hiddenCustomField('check_user_tier', 'boolean', true, 'Check user tier limits')
->hiddenCustomField('allow_unlimited_tier', 'string', 'premium', 'Tier with unlimited access');
}
public function restriction(FlowTaskContext $context): RestrictionResult
{
$order = $context->subject();
$user = $context->user();
$config = $context->config();
if (!$user) {
return RestrictionResult::deny(
'USER_REQUIRED',
'User is required for quota checks'
);
}
$maxPerDay = $config['max_orders_per_day'] ?? 10;
$maxPerMonth = $config['max_orders_per_month'] ?? 100;
$maxAmountPerDay = $config['max_total_amount_per_day'] ?? 5000;
$checkTier = $config['check_user_tier'] ?? true;
$unlimitedTier = $config['allow_unlimited_tier'] ?? 'premium';
// Check if user has unlimited tier
if ($checkTier && $user->tier === $unlimitedTier) {
return RestrictionResult::allow();
}
// 1. Daily order count check
$dailyCount = $this->getDailyOrderCount($user);
if ($dailyCount >= $maxPerDay) {
return RestrictionResult::deny(
'DAILY_QUOTA_EXCEEDED',
"You have reached the daily limit of {$maxPerDay} orders. Please try again tomorrow."
);
}
// 2. Monthly order count check
$monthlyCount = $this->getMonthlyOrderCount($user);
if ($monthlyCount >= $maxPerMonth) {
return RestrictionResult::deny(
'MONTHLY_QUOTA_EXCEEDED',
"You have reached the monthly limit of {$maxPerMonth} orders."
);
}
// 3. Daily amount check
$dailyAmount = $this->getDailyOrderAmount($user);
$newTotal = $dailyAmount + $order->total;
if ($newTotal > $maxAmountPerDay) {
return RestrictionResult::deny(
'DAILY_AMOUNT_EXCEEDED',
"This order would exceed your daily spending limit of {$maxAmountPerDay}. " .
"Current daily total: {$dailyAmount}, Order amount: {$order->total}"
);
}
// All quota checks passed
return RestrictionResult::allow();
}
protected function getDailyOrderCount(User $user): int
{
$cacheKey = "user_daily_orders_{$user->id}_" . now()->format('Y-m-d');
return Cache::remember($cacheKey, 3600, function () use ($user) {
return $user->orders()
->whereDate('created_at', today())
->count();
});
}
protected function getMonthlyOrderCount(User $user): int
{
$cacheKey = "user_monthly_orders_{$user->id}_" . now()->format('Y-m');
return Cache::remember($cacheKey, 3600, function () use ($user) {
return $user->orders()
->whereYear('created_at', now()->year)
->whereMonth('created_at', now()->month)
->count();
});
}
protected function getDailyOrderAmount(User $user): float
{
$cacheKey = "user_daily_amount_{$user->id}_" . now()->format('Y-m-d');
return Cache::remember($cacheKey, 3600, function () use ($user) {
return $user->orders()
->whereDate('created_at', today())
->sum('total');
});
}
}
Example 9: Complete Real-World System - Order Cancellation Workflow
A complete example showing all restriction patterns working together:
namespace App\Flows\Order;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use JobMetric\Flow\Contracts\AbstractRestrictionTask;
use JobMetric\Flow\Support\FlowTaskContext;
use JobMetric\Flow\Support\FlowTaskDefinition;
use JobMetric\Flow\Support\RestrictionResult;
use JobMetric\Form\FormBuilder;
class CompleteOrderCancellationRestrictionTask extends AbstractRestrictionTask
{
public static function subject(): string
{
return \App\Models\Order::class;
}
public static function definition(): FlowTaskDefinition
{
return new FlowTaskDefinition(
title: 'flow::base.task.complete_cancellation_restriction.title',
description: 'Complete order cancellation restrictions',
);
}
public function form(): FormBuilder
{
return (new FormBuilder)
->hiddenCustomField('max_cancellation_hours', 'integer', 24, 'Max hours after creation')
->hiddenCustomField('require_reason', 'boolean', true, 'Require cancellation reason')
->hiddenCustomField('check_rate_limit', 'boolean', true, 'Check rate limit')
->hiddenCustomField('max_cancellations_per_month', 'integer', 5, 'Max cancellations per month')
->hiddenCustomField('allow_admin_override', 'boolean', true, 'Allow admin override')
->hiddenCustomField('check_inventory', 'boolean', true, 'Check inventory restock')
->hiddenCustomField('check_payment', 'boolean', true, 'Check payment status')
->hiddenCustomField('check_disputes', 'boolean', true, 'Check active disputes');
}
public function restriction(FlowTaskContext $context): RestrictionResult
{
$order = $context->subject();
$user = $context->user();
$payload = $context->payload();
$config = $context->config();
// Log restriction check start
Log::info('Order cancellation restriction check', [
'order_id' => $order->id,
'user_id' => $user?->id,
]);
// Admin override (early exit)
if (($config['allow_admin_override'] ?? true) && $user && $user->hasRole('admin')) {
Log::info('Admin override granted', ['order_id' => $order->id, 'user_id' => $user->id]);
return RestrictionResult::allow();
}
// 1. User checks
if (!$user) {
return $this->denyWithLog('USER_NOT_AUTHENTICATED', 'User must be authenticated', $order);
}
if (!$user->can('cancel', $order)) {
return $this->denyWithLog('PERMISSION_DENIED', 'No permission to cancel', $order, $user);
}
if ($order->user_id !== $user->id && !$user->hasRole('admin')) {
return $this->denyWithLog('OWNERSHIP_MISMATCH', 'Not your order', $order, $user);
}
// 2. Order status checks
if (in_array($order->status, ['shipped', 'delivered', 'cancelled', 'refunded'])) {
return $this->denyWithLog(
'INVALID_STATUS',
"Cannot cancel order with status: {$order->status}",
$order,
$user
);
}
// 3. Time-based checks
$maxHours = $config['max_cancellation_hours'] ?? 24;
$hoursSinceCreated = $order->created_at->diffInHours(now());
if ($hoursSinceCreated > $maxHours) {
return $this->denyWithLog(
'TIME_LIMIT_EXCEEDED',
"Order is older than {$maxHours} hours",
$order,
$user
);
}
// 4. Payload validation
if (($config['require_reason'] ?? true) && empty($payload['reason'])) {
return $this->denyWithLog('REASON_REQUIRED', 'Cancellation reason required', $order, $user);
}
// 5. Rate limiting
if ($config['check_rate_limit'] ?? true) {
$maxPerMonth = $config['max_cancellations_per_month'] ?? 5;
$count = $this->getMonthlyCancellationCount($user);
if ($count >= $maxPerMonth) {
return $this->denyWithLog(
'RATE_LIMIT_EXCEEDED',
"Exceeded {$maxPerMonth} cancellations per month",
$order,
$user
);
}
}
// 6. Payment checks
if ($config['check_payment'] ?? true) {
if ($order->payment_status === 'refunded') {
return $this->denyWithLog('PAYMENT_ALREADY_REFUNDED', 'Payment already refunded', $order, $user);
}
}
// 7. Dispute checks
if (($config['check_disputes'] ?? true) && $this->hasActiveDispute($order)) {
return $this->denyWithLog('ACTIVE_DISPUTE', 'Order has active dispute', $order, $user);
}
// 8. Inventory checks
if (($config['check_inventory'] ?? true)) {
$restockInventory = $payload['restock_inventory'] ?? true;
if ($restockInventory && !$this->canRestockInventory($order)) {
return $this->denyWithLog('INVENTORY_RESTOCK_FAILED', 'Cannot restock inventory', $order, $user);
}
}
// All checks passed
Log::info('Order cancellation restriction passed', [
'order_id' => $order->id,
'user_id' => $user->id,
]);
return RestrictionResult::allow();
}
protected function denyWithLog(string $code, string $message, Order $order, ?User $user = null): RestrictionResult
{
Log::warning('Order cancellation restriction denied', [
'code' => $code,
'message' => $message,
'order_id' => $order->id,
'user_id' => $user?->id,
]);
return RestrictionResult::deny($code, $message);
}
protected function getMonthlyCancellationCount(User $user): int
{
$cacheKey = "user_cancellations_{$user->id}_" . now()->format('Y-m');
return Cache::remember($cacheKey, 3600, function () use ($user) {
return $user->orders()
->where('status', 'cancelled')
->whereYear('cancelled_at', now()->year)
->whereMonth('cancelled_at', now()->month)
->count();
});
}
protected function hasActiveDispute(Order $order): bool
{
return $order->disputes()
->where('status', 'open')
->exists();
}
protected function canRestockInventory(Order $order): bool
{
foreach ($order->items as $item) {
if (!$item->product->canRestock()) {
return false;
}
}
return true;
}
}
Key Points:
- Comprehensive logging
- Early exit for admin override
- Multiple validation layers
- Caching for performance
- Detailed error messages
- Production-ready implementation
Code Conventions
Recommended Code Format
Use uppercase with underscores:
// ✅ Good
RestrictionResult::deny('PERMISSION_DENIED', 'Message');
RestrictionResult::deny('ORDER_SHIPPED', 'Message');
RestrictionResult::deny('TIME_LIMIT_EXCEEDED', 'Message');
// ❌ Bad
RestrictionResult::deny('permission-denied', 'Message');
RestrictionResult::deny('PermissionDenied', 'Message');
Common Code Patterns
PERMISSION_DENIED- User lacks permissionSTATUS_INVALID- Invalid model statusTIME_LIMIT_EXCEEDED- Time constraint violationREQUIRED_FIELD_MISSING- Required field not providedBUSINESS_RULE_VIOLATION- Business rule violationRESOURCE_LOCKED- Resource is lockedQUOTA_EXCEEDED- Quota limit exceeded
Error Handling and Integration
How Restriction Failures Work
When a restriction denies a transition, the FlowTransition service will:
- Throw a
TaskRestrictionException - Include the code and message from
RestrictionResult - Prevent the transition from executing
- Stop all subsequent tasks (validation and action tasks won't run)
Basic Error Handling
use JobMetric\Flow\Facades\FlowTransition;
use JobMetric\Flow\Exceptions\TaskRestrictionException;
try {
$result = FlowTransition::runner('cancel_order', $order, $payload, $user);
// Transition succeeded
return response()->json([
'success' => true,
'data' => $result->getData(),
]);
} catch (TaskRestrictionException $e) {
// Restriction denied
$code = $e->getCode();
$message = $e->getMessage();
return response()->json([
'success' => false,
'error' => $code,
'message' => $message,
], 403);
}
Advanced Error Handling with Code-Based Responses
Handle different restriction codes with specific responses:
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use JobMetric\Flow\Facades\FlowTransition;
use JobMetric\Flow\Exceptions\TaskRestrictionException;
class OrderController extends Controller
{
public function cancel(Request $request, Order $order): JsonResponse
{
$payload = [
'reason' => $request->input('reason'),
'restock_inventory' => $request->boolean('restock_inventory', true),
];
try {
$result = FlowTransition::runner('cancel_order', $order, $payload, auth()->user());
return response()->json([
'success' => true,
'message' => 'Order cancelled successfully',
'data' => $result->getData(),
]);
} catch (TaskRestrictionException $e) {
return $this->handleRestrictionError($e, $order);
}
}
protected function handleRestrictionError(TaskRestrictionException $e, Order $order): JsonResponse
{
$code = $e->getCode();
$message = $e->getMessage();
// Handle specific restriction codes
return match ($code) {
'USER_NOT_AUTHENTICATED' => response()->json([
'success' => false,
'error' => $code,
'message' => $message,
'action' => 'login_required',
], 401),
'PERMISSION_DENIED' => response()->json([
'success' => false,
'error' => $code,
'message' => $message,
'action' => 'contact_support',
], 403),
'ORDER_SHIPPED' => response()->json([
'success' => false,
'error' => $code,
'message' => $message,
'action' => 'contact_shipping',
'shipping_info' => [
'tracking_number' => $order->tracking_number,
'shipped_at' => $order->shipped_at,
],
], 403),
'TIME_LIMIT_EXCEEDED' => response()->json([
'success' => false,
'error' => $code,
'message' => $message,
'action' => 'contact_support',
'order_age_hours' => $order->created_at->diffInHours(now()),
], 403),
'RATE_LIMIT_EXCEEDED' => response()->json([
'success' => false,
'error' => $code,
'message' => $message,
'action' => 'wait_or_contact',
'retry_after' => now()->addDay()->toIso8601String(),
], 429),
'REASON_REQUIRED' => response()->json([
'success' => false,
'error' => $code,
'message' => $message,
'action' => 'provide_reason',
'validation' => [
'reason' => ['required', 'string', 'min:10'],
],
], 422),
default => response()->json([
'success' => false,
'error' => $code,
'message' => $message,
'action' => 'contact_support',
], 403),
};
}
}
Frontend Integration Example
Handle restriction errors in frontend applications:
// React/Vue/Angular example
async function cancelOrder(orderId, reason) {
try {
const response = await fetch(`/api/orders/${orderId}/cancel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ reason }),
});
const data = await response.json();
if (data.success) {
// Show success message
showNotification('Order cancelled successfully', 'success');
return data;
} else {
// Handle restriction error
handleRestrictionError(data);
}
} catch (error) {
handleError(error);
}
}
function handleRestrictionError(data) {
const { error, message, action } = data;
switch (error) {
case 'USER_NOT_AUTHENTICATED':
redirectToLogin();
break;
case 'PERMISSION_DENIED':
showError(message);
showContactSupportButton();
break;
case 'ORDER_SHIPPED':
showError(message);
showShippingInfo(data.shipping_info);
break;
case 'TIME_LIMIT_EXCEEDED':
showError(message);
showContactSupportButton();
break;
case 'RATE_LIMIT_EXCEEDED':
showError(message);
showRetryAfter(data.retry_after);
break;
case 'REASON_REQUIRED':
showValidationError(data.validation);
break;
default:
showError(message);
showContactSupportButton();
}
}
Logging and Monitoring
Log restriction failures for monitoring and analytics:
use Illuminate\Support\Facades\Log;
use JobMetric\Flow\Exceptions\TaskRestrictionException;
try {
$result = FlowTransition::runner('cancel_order', $order, $payload, $user);
} catch (TaskRestrictionException $e) {
// Log restriction failure
Log::warning('Order cancellation restricted', [
'order_id' => $order->id,
'user_id' => $user?->id,
'restriction_code' => $e->getCode(),
'restriction_message' => $e->getMessage(),
'order_status' => $order->status,
'order_total' => $order->total,
'payload' => $payload,
]);
// Track in analytics
analytics()->track('order_cancellation_restricted', [
'restriction_code' => $e->getCode(),
'order_id' => $order->id,
]);
// Send to error tracking service
if (app()->bound('sentry')) {
app('sentry')->captureException($e, [
'tags' => [
'restriction_code' => $e->getCode(),
'order_id' => $order->id,
],
]);
}
throw $e; // Re-throw to handle in controller
}
Testing Restriction Results
Test restriction tasks and their results:
namespace Tests\Feature\Flows;
use App\Flows\Order\RestrictCancellationRestrictionTask;
use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use JobMetric\Flow\DTO\TransitionResult;
use JobMetric\Flow\Support\FlowTaskContext;
use JobMetric\Flow\Support\RestrictionResult;
use Tests\TestCase;
class RestrictionTaskTest extends TestCase
{
use RefreshDatabase;
public function test_allows_cancellation_when_all_conditions_met(): void
{
$order = Order::factory()->create(['status' => 'pending']);
$user = User::factory()->create();
$user->givePermissionTo('cancel', Order::class);
$task = new RestrictCancellationRestrictionTask();
$context = new FlowTaskContext(
$order,
TransitionResult::success(),
['reason' => 'Customer request'],
$user
);
$result = $task->restriction($context);
$this->assertTrue($result->allowed());
$this->assertNull($result->code());
}
public function test_denies_cancellation_when_order_shipped(): void
{
$order = Order::factory()->create(['status' => 'shipped']);
$user = User::factory()->create();
$task = new RestrictCancellationRestrictionTask();
$context = new FlowTaskContext($order, TransitionResult::success(), [], $user);
$result = $task->restriction($context);
$this->assertFalse($result->allowed());
$this->assertEquals('ORDER_SHIPPED', $result->code());
$this->assertNotNull($result->message());
}
public function test_denies_cancellation_without_permission(): void
{
$order = Order::factory()->create(['status' => 'pending']);
$user = User::factory()->create();
// User does NOT have permission
$task = new RestrictCancellationRestrictionTask();
$context = new FlowTaskContext($order, TransitionResult::success(), [], $user);
$result = $task->restriction($context);
$this->assertFalse($result->allowed());
$this->assertEquals('PERMISSION_DENIED', $result->code());
}
public function test_denies_cancellation_without_reason(): void
{
$order = Order::factory()->create(['status' => 'pending']);
$user = User::factory()->create();
$user->givePermissionTo('cancel', Order::class);
$task = new RestrictCancellationRestrictionTask();
$context = new FlowTaskContext(
$order,
TransitionResult::success(),
[], // No reason provided
$user
);
$result = $task->restriction($context);
$this->assertFalse($result->allowed());
$this->assertEquals('REASON_REQUIRED', $result->code());
}
}
Best Practices
-
Use Descriptive Codes: Choose codes that clearly identify the restriction reason
// ✅ Good
RestrictionResult::deny('ORDER_SHIPPED', 'Message');
// ❌ Bad
RestrictionResult::deny('ERROR', 'Message'); -
Provide User-Friendly Messages: Messages should be understandable by end users
// ✅ Good
RestrictionResult::deny('ORDER_SHIPPED', 'Cannot cancel shipped orders');
// ❌ Bad
RestrictionResult::deny('ORDER_SHIPPED', 'Error code 42'); -
Check Multiple Conditions: Evaluate all conditions before allowing
// Check permission
if (!$user->can('cancel', $order)) {
return RestrictionResult::deny('PERMISSION_DENIED', '...');
}
// Check status
if ($order->status === 'shipped') {
return RestrictionResult::deny('ORDER_SHIPPED', '...');
}
return RestrictionResult::allow(); -
Use Config for Flexibility: Make restrictions configurable when possible
$allowedStatuses = $context->config()['allowed_statuses'] ?? []; -
Return Early: Return deny results as soon as a condition fails
// ✅ Good: Early return
if ($order->status === 'shipped') {
return RestrictionResult::deny('ORDER_SHIPPED', '...');
}
// ❌ Bad: Nested conditions
if ($order->status !== 'shipped') {
if ($user->can('cancel', $order)) {
return RestrictionResult::allow();
}
}
Integration with FlowTransition
Execution Order
Restriction tasks are executed first in the workflow transition:
// FlowTransition execution order:
// 1. Restriction tasks (can deny) ← Executed FIRST
// 2. Validation tasks (can fail) ← Only if restrictions pass
// 3. Action tasks (execute) ← Only if restrictions pass AND validation passes
How Restrictions Stop Transitions
If any restriction task returns deny(), the transition is stopped immediately:
// Inside FlowTransition::runner()
foreach ($restrictionTasks as $task) {
$result = $task->restriction($context);
if (!$result->allowed()) {
// Transition STOPS here
// Validation tasks won't run
// Action tasks won't run
throw new TaskRestrictionException(
$result->code(),
$result->message()
);
}
}
// Only reached if ALL restrictions allow
// Continue with validation tasks...
Multiple Restrictions
When multiple restriction tasks exist, all must allow:
// Example: Three restriction tasks
$restriction1 = new PermissionRestrictionTask();
$restriction2 = new OrderStatusRestrictionTask();
$restriction3 = new TimeBasedRestrictionTask();
// All three must return RestrictionResult::allow()
// If ANY returns deny(), transition stops
Real-World Workflow Example
Complete example showing restriction flow in a real system:
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use JobMetric\Flow\Facades\FlowTransition;
use JobMetric\Flow\Exceptions\TaskRestrictionException;
class OrderController extends Controller
{
/**
* Cancel an order with comprehensive restriction handling
*/
public function cancel(Request $request, Order $order): JsonResponse
{
// Validate request
$validated = $request->validate([
'reason' => 'required|string|min:10|max:500',
'restock_inventory' => 'boolean',
]);
try {
// Execute transition - restrictions are checked automatically
$result = FlowTransition::runner(
'cancel_order',
$order,
$validated,
auth()->user()
);
// Transition succeeded (all restrictions passed)
return response()->json([
'success' => true,
'message' => 'Order cancelled successfully',
'data' => $result->getData(),
'messages' => $result->getMessages(),
]);
} catch (TaskRestrictionException $e) {
// A restriction denied the transition
return $this->handleRestrictionFailure($e, $order);
}
}
protected function handleRestrictionFailure(TaskRestrictionException $e, Order $order): JsonResponse
{
$code = $e->getCode();
$message = $e->getMessage();
// Map restriction codes to HTTP status codes
$statusCode = match ($code) {
'USER_NOT_AUTHENTICATED' => 401,
'PERMISSION_DENIED' => 403,
'RATE_LIMIT_EXCEEDED' => 429,
default => 403,
};
// Provide actionable error responses
$response = [
'success' => false,
'error' => [
'code' => $code,
'message' => $message,
],
'order' => [
'id' => $order->id,
'status' => $order->status,
],
];
// Add context-specific information
switch ($code) {
case 'TIME_LIMIT_EXCEEDED':
$response['context'] = [
'order_created_at' => $order->created_at->toIso8601String(),
'hours_since_creation' => $order->created_at->diffInHours(now()),
];
break;
case 'ORDER_SHIPPED':
$response['context'] = [
'shipped_at' => $order->shipped_at?->toIso8601String(),
'tracking_number' => $order->tracking_number,
];
break;
case 'RATE_LIMIT_EXCEEDED':
$response['context'] = [
'retry_after' => now()->addDay()->toIso8601String(),
];
break;
}
return response()->json($response, $statusCode);
}
}
Debugging Restrictions
Debug restriction failures during development:
use JobMetric\Flow\Facades\FlowTransition;
use JobMetric\Flow\Exceptions\TaskRestrictionException;
use Illuminate\Support\Facades\Log;
try {
$result = FlowTransition::runner('cancel_order', $order, $payload, $user);
} catch (TaskRestrictionException $e) {
// Debug information
Log::debug('Restriction failure', [
'code' => $e->getCode(),
'message' => $e->getMessage(),
'order_id' => $order->id,
'order_status' => $order->status,
'user_id' => $user?->id,
'payload' => $payload,
'stack_trace' => $e->getTraceAsString(),
]);
// In development, show detailed error
if (app()->environment('local')) {
return response()->json([
'error' => $e->getCode(),
'message' => $e->getMessage(),
'debug' => [
'order' => $order->toArray(),
'user' => $user?->toArray(),
'payload' => $payload,
],
], 403);
}
throw $e;
}
Best Practices for Restriction Codes
1. Use Consistent Naming:
// ✅ Good: Clear, descriptive codes
'PERMISSION_DENIED'
'ORDER_SHIPPED'
'TIME_LIMIT_EXCEEDED'
'RATE_LIMIT_EXCEEDED'
// ❌ Bad: Vague codes
'ERROR'
'FAILED'
'NOT_ALLOWED'
2. Group Related Codes:
// Permission-related
'PERMISSION_DENIED'
'INSUFFICIENT_ROLE'
'OWNERSHIP_MISMATCH'
// Status-related
'ORDER_SHIPPED'
'ORDER_DELIVERED'
'ORDER_CANCELLED'
// Time-related
'TIME_LIMIT_EXCEEDED'
'OUTSIDE_BUSINESS_HOURS'
'DATE_BLOCKED'
3. Provide Actionable Messages:
// ✅ Good: Tells user what to do
RestrictionResult::deny(
'TIME_LIMIT_EXCEEDED',
'Orders can only be cancelled within 24 hours. Please contact support for assistance.'
);
// ❌ Bad: Doesn't help user
RestrictionResult::deny(
'TIME_LIMIT_EXCEEDED',
'Time limit exceeded'
);
4. Use Codes for Programmatic Handling:
// Frontend can handle specific codes
if (error.code === 'RATE_LIMIT_EXCEEDED') {
showRetryAfterMessage(error.retry_after);
} else if (error.code === 'PERMISSION_DENIED') {
showContactSupportButton();
}
Related Documentation
- FlowTaskContext - Task execution context
- TransitionResult - Transition result DTO
- FlowTransition Service - Transition execution
- FlowTask Service - Task management
- MakeTask Command - Generating task drivers
- FlowTaskRegistry - Task driver registry