Skip to content

Custom Evaluators

Braden Keith edited this page Sep 20, 2025 · 1 revision

Custom Evaluators

Create your own rule types to handle specialized availability logic.

Creating a Custom Evaluator

Step 1: Implement the Interface

<?php

namespace App\Availability\Evaluators;

use RomegaSoftware\Availability\Contracts\RuleEvaluator;
use RomegaSoftware\Availability\Contracts\AvailabilitySubject;
use Carbon\CarbonInterface;

class MinimumNoticeEvaluator implements RuleEvaluator
{
    public function matches(array $config, CarbonInterface $moment, AvailabilitySubject $subject): bool
    {
        $hoursNotice = $config['hours'] ?? 24;
        $minimumTime = now()->addHours($hoursNotice);
        
        // Available only if requested time is far enough in future
        return $moment->isAfter($minimumTime);
    }
}

Step 2: Register the Evaluator

In config/availability.php:

'rule_types' => [
    // ... existing types ...
    'minimum_notice' => \App\Availability\Evaluators\MinimumNoticeEvaluator::class,
],

Or register dynamically in a service provider:

use RomegaSoftware\Availability\Support\RuleEvaluatorRegistry;

public function boot()
{
    $registry = app(RuleEvaluatorRegistry::class);
    $registry->register('minimum_notice', new MinimumNoticeEvaluator());
}

Step 3: Use Your Custom Rule

$service->availabilityRules()->create([
    'type' => 'minimum_notice',
    'config' => ['hours' => 48], // Require 48 hours notice
    'effect' => Effect::Allow,
    'priority' => 25,
]);

Real-World Examples

Business Days Calculator

namespace App\Availability\Evaluators;

class BusinessDaysEvaluator implements RuleEvaluator
{
    public function matches(array $config, CarbonInterface $moment, AvailabilitySubject $subject): bool
    {
        $daysAhead = $config['days_ahead'] ?? 3;
        $includeToday = $config['include_today'] ?? false;
        
        $start = $includeToday ? now()->startOfDay() : now()->addDay()->startOfDay();
        $businessDays = 0;
        $current = $start->copy();
        
        while ($businessDays < $daysAhead && $current->diffInDays(now()) < 365) {
            if ($current->isWeekday()) {
                $businessDays++;
                if ($current->isSameDay($moment)) {
                    return true;
                }
            }
            $current->addDay();
        }
        
        return false;
    }
}

// Usage: Available only within next 3 business days
$delivery->availabilityRules()->create([
    'type' => 'business_days',
    'config' => [
        'days_ahead' => 3,
        'include_today' => false,
    ],
    'effect' => Effect::Allow,
    'priority' => 20,
]);

Capacity-Based Evaluator

namespace App\Availability\Evaluators;

class CapacityEvaluator implements RuleEvaluator
{
    public function matches(array $config, CarbonInterface $moment, AvailabilitySubject $subject): bool
    {
        $requiredCapacity = $config['required'] ?? 1;
        $bufferPercentage = $config['buffer'] ?? 0;
        
        // Get current bookings for this time
        $usedCapacity = $subject->bookings()
            ->where('start_time', '<=', $moment)
            ->where('end_time', '>', $moment)
            ->sum('capacity_used');
        
        $totalCapacity = $subject->total_capacity;
        $bufferAmount = $totalCapacity * ($bufferPercentage / 100);
        $availableCapacity = $totalCapacity - $usedCapacity - $bufferAmount;
        
        return $availableCapacity >= $requiredCapacity;
    }
}

// Usage: Require at least 10 seats with 20% buffer
$venue->availabilityRules()->create([
    'type' => 'capacity',
    'config' => [
        'required' => 10,
        'buffer' => 20,
    ],
    'effect' => Effect::Allow,
    'priority' => 30,
]);

Weather-Based Evaluator

namespace App\Availability\Evaluators;

use Illuminate\Support\Facades\Http;

class WeatherEvaluator implements RuleEvaluator
{
    public function matches(array $config, CarbonInterface $moment, AvailabilitySubject $subject): bool
    {
        $conditions = $config['conditions'] ?? ['clear', 'partly_cloudy'];
        $maxWindSpeed = $config['max_wind_speed'] ?? 25;
        $minTemp = $config['min_temperature'] ?? 10;
        
        // Check if moment is within forecast range (e.g., next 7 days)
        if ($moment->isAfter(now()->addDays(7))) {
            // Too far in future, assume available
            return true;
        }
        
        try {
            $forecast = Cache::remember(
                "weather.{$subject->latitude}.{$subject->longitude}.{$moment->format('Y-m-d')}",
                3600,
                function () use ($subject, $moment) {
                    return Http::get('https://api.weather.com/forecast', [
                        'lat' => $subject->latitude,
                        'lon' => $subject->longitude,
                        'date' => $moment->format('Y-m-d'),
                    ])->json();
                }
            );
            
            $weather = $forecast['hourly'][$moment->hour] ?? null;
            
            if (!$weather) {
                return true; // No data, assume available
            }
            
            return in_array($weather['condition'], $conditions)
                && $weather['wind_speed'] <= $maxWindSpeed
                && $weather['temperature'] >= $minTemp;
                
        } catch (\Exception $e) {
            // Weather API failed, default to available
            Log::warning('Weather API failed', ['error' => $e->getMessage()]);
            return true;
        }
    }
}

// Usage: Outdoor venue only available in good weather
$outdoorVenue->availabilityRules()->create([
    'type' => 'weather',
    'config' => [
        'conditions' => ['clear', 'partly_cloudy', 'cloudy'],
        'max_wind_speed' => 20,
        'min_temperature' => 15,
    ],
    'effect' => Effect::Allow,
    'priority' => 40,
]);

Quota-Based Evaluator

namespace App\Availability\Evaluators;

class QuotaEvaluator implements RuleEvaluator
{
    public function matches(array $config, CarbonInterface $moment, AvailabilitySubject $subject): bool
    {
        $quotaType = $config['type'] ?? 'daily';
        $limit = $config['limit'] ?? 10;
        
        $query = $subject->usages();
        
        switch ($quotaType) {
            case 'hourly':
                $query->where('created_at', '>=', $moment->copy()->startOfHour())
                      ->where('created_at', '<', $moment->copy()->endOfHour());
                break;
                
            case 'daily':
                $query->whereDate('created_at', $moment->toDateString());
                break;
                
            case 'weekly':
                $query->where('created_at', '>=', $moment->copy()->startOfWeek())
                      ->where('created_at', '<', $moment->copy()->endOfWeek());
                break;
                
            case 'monthly':
                $query->whereYear('created_at', $moment->year)
                      ->whereMonth('created_at', $moment->month);
                break;
        }
        
        $currentUsage = $query->count();
        
        return $currentUsage < $limit;
    }
}

// Usage: Limit to 100 daily API calls
$apiEndpoint->availabilityRules()->create([
    'type' => 'quota',
    'config' => [
        'type' => 'daily',
        'limit' => 100,
    ],
    'effect' => Effect::Allow,
    'priority' => 35,
]);

Advanced Patterns

Stateful Evaluator with Dependencies

namespace App\Availability\Evaluators;

class DependencyEvaluator implements RuleEvaluator
{
    private $dependencyChecker;
    
    public function __construct(DependencyChecker $checker)
    {
        $this->dependencyChecker = $checker;
    }
    
    public function matches(array $config, CarbonInterface $moment, AvailabilitySubject $subject): bool
    {
        $requiredResources = $config['requires'] ?? [];
        
        foreach ($requiredResources as $resourceId) {
            if (!$this->dependencyChecker->isAvailable($resourceId, $moment)) {
                return false;
            }
        }
        
        return true;
    }
}

// Register with dependency injection
$registry->register('dependencies', function () {
    return app(DependencyEvaluator::class);
});

Composite Evaluator

namespace App\Availability\Evaluators;

class CompositeEvaluator implements RuleEvaluator
{
    private $registry;
    
    public function __construct(RuleEvaluatorRegistry $registry)
    {
        $this->registry = $registry;
    }
    
    public function matches(array $config, CarbonInterface $moment, AvailabilitySubject $subject): bool
    {
        $operator = $config['operator'] ?? 'AND';
        $rules = $config['rules'] ?? [];
        
        $results = [];
        foreach ($rules as $rule) {
            $evaluator = $this->registry->get($rule['type']);
            if ($evaluator) {
                $results[] = $evaluator->matches(
                    $rule['config'] ?? [],
                    $moment,
                    $subject
                );
            }
        }
        
        if ($operator === 'AND') {
            return !in_array(false, $results, true);
        } elseif ($operator === 'OR') {
            return in_array(true, $results, true);
        } elseif ($operator === 'XOR') {
            $trueCount = count(array_filter($results));
            return $trueCount === 1;
        }
        
        return false;
    }
}

// Usage: Complex rule composition
$resource->availabilityRules()->create([
    'type' => 'composite',
    'config' => [
        'operator' => 'AND',
        'rules' => [
            ['type' => 'weekdays', 'config' => ['days' => [1,2,3,4,5]]],
            ['type' => 'time_of_day', 'config' => ['from' => '09:00', 'to' => '17:00']],
            ['type' => 'minimum_notice', 'config' => ['hours' => 24]],
        ],
    ],
    'effect' => Effect::Allow,
    'priority' => 50,
]);

Testing Custom Evaluators

namespace Tests\Unit\Availability;

use App\Availability\Evaluators\MinimumNoticeEvaluator;
use Carbon\Carbon;

class MinimumNoticeEvaluatorTest extends TestCase
{
    private MinimumNoticeEvaluator $evaluator;
    private $subject;
    
    protected function setUp(): void
    {
        parent::setUp();
        $this->evaluator = new MinimumNoticeEvaluator();
        $this->subject = Resource::factory()->create();
    }
    
    public function test_requires_minimum_notice()
    {
        $config = ['hours' => 24];
        
        // Test: 12 hours from now (not enough notice)
        $tooSoon = now()->addHours(12);
        $this->assertFalse(
            $this->evaluator->matches($config, $tooSoon, $this->subject)
        );
        
        // Test: 25 hours from now (enough notice)
        $farEnough = now()->addHours(25);
        $this->assertTrue(
            $this->evaluator->matches($config, $farEnough, $this->subject)
        );
    }
    
    public function test_uses_default_notice_period()
    {
        $config = []; // No hours specified
        
        // Should use default of 24 hours
        $justEnough = now()->addHours(24)->addMinute();
        $this->assertTrue(
            $this->evaluator->matches($config, $justEnough, $this->subject)
        );
    }
}

Best Practices

1. Keep Evaluators Focused

Each evaluator should handle one specific type of logic:

// Good: Single responsibility
class HolidayEvaluator { /* checks holidays */ }
class MaintenanceEvaluator { /* checks maintenance */ }

// Bad: Too many responsibilities
class ComplexEvaluator { /* checks holidays, maintenance, capacity, weather... */ }

2. Handle Edge Cases

public function matches(array $config, CarbonInterface $moment, AvailabilitySubject $subject): bool
{
    // Validate config
    if (!isset($config['required_field'])) {
        return false; // Safe default
    }
    
    // Handle null values
    $value = $config['value'] ?? $this->getDefaultValue();
    
    // Prevent division by zero
    if ($divisor == 0) {
        return false;
    }
    
    // Your logic here...
}

3. Make Evaluators Testable

// Good: Can be tested in isolation
class PureEvaluator implements RuleEvaluator
{
    public function matches(array $config, CarbonInterface $moment, AvailabilitySubject $subject): bool
    {
        return $moment->hour >= $config['start_hour']
            && $moment->hour < $config['end_hour'];
    }
}

// Harder to test: Direct database/API calls
class CoupledEvaluator implements RuleEvaluator
{
    public function matches(array $config, CarbonInterface $moment, AvailabilitySubject $subject): bool
    {
        return DB::table('some_table')->where(...)->exists(); // Hard to mock
    }
}

4. Document Configuration

/**
 * Evaluates availability based on minimum advance notice
 * 
 * Configuration:
 * - hours (int): Minimum hours of advance notice required (default: 24)
 * - business_hours_only (bool): Count only business hours (default: false)
 * - exclude_weekends (bool): Don't count weekend hours (default: false)
 * 
 * Example:
 * ['hours' => 48, 'business_hours_only' => true]
 */
class MinimumNoticeEvaluator implements RuleEvaluator
{
    // ...
}

Next Steps

Getting Started

Installation
Set up the package in your Laravel app

Quick Start
Get running in 5 minutes

Basic Usage
Common patterns and examples


Core Concepts

How It Works
Understanding the evaluation engine

Rule Types
Available rule types and configurations

Priority System
How rule priority affects evaluation


Advanced Topics

Inventory Gates
Dynamic availability based on stock

Custom Evaluators
Build your own rule types

Complex Scenarios
Real-world implementation patterns

Performance Tips
Optimization strategies


API Reference

Configuration
Package configuration options

Models & Traits
Available models and traits

Testing
Testing your availability rules

Clone this wiki locally