Skip to main content

Rule Evaluators Overview

Role rules are rows in role_rules with a driver name and JSON payload. During permission resolution, HasRole loads each role (and its ancestors) and runs every attached rule. All rules must pass or that role is excluded from the effective set.

Evaluators implement JobMetric\Rolix\Contracts\RuleEvaluatorContract and are typically subclasses of JobMetric\Rolix\Contracts\AbstractRuleEvaluator. Drivers are registered by name() (not driver()).

How Evaluation Works

  1. HasRole::getRoleWithAncestorsIfValid() collects the role plus ancestors.
  2. For each role, evaluateRoleRules() loads rules and resolves the driver via RuleEvaluatorRegistry::get($rule->driver).
  3. It calls $driver->evaluate($payload, $this) where $this is the personable model (the user / entity using HasRole).
  4. If any rule returns false, or the driver is missing, the role is filtered out.
// Conceptual flow inside HasRole
foreach ($role->rules as $rule) {
$driver = RuleEvaluatorRegistry::get($rule->driver);
$payload = is_array($rule->payload) ? $rule->payload : [];

if (! $driver->evaluate($payload, $this)) {
return false; // role excluded
}
}

Contract & Abstract Base

RuleEvaluatorContract

MethodSignaturePurpose
name()stringUnique driver key stored on role_rules.driver and used for registry lookup
evaluate()(array $rule, mixed $context): boolReturn true to keep the role
form()FormBuilderUI form for configuring the payload
fields()arrayBuilt form array (form()->build()->toArray()) for UI consumers

AbstractRuleEvaluator

Provides helpers used by built-ins:

HelperPurpose
string($rule, $key, $default)Trimmed string from payload
int($rule, $key, $default)Integer from payload
list($rule, $key)Array or CSV/ ;/-separated list
request()Current HTTP request when available
contextValue($context, $key)Attribute from model / array / object
settingsForm($callback)Single-tab FormBuilder named after name()
fields()Default implementation via form()->build()->toArray()

Attaching Rules to Roles

Pass a rules array when storing or updating a role through the Role service (validated by StoreRoleRequest / UpdateRoleRequest):

use JobMetric\Rolix\Facades\Role;

Role::store([
'name' => 'Office Hours Admin',
'type' => 'system',
'allow' => ['users.view'],
'rules' => [
[
'driver' => 'time',
'payload' => [
'from' => '09:00',
'to' => '18:00',
'timezone' => 'Asia/Tehran',
],
],
[
'driver' => 'weekday',
'payload' => [
'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'],
],
],
],
]);

Sync behavior (Role::syncRules)

CallBehavior
Store with rulesAfter create, deletes existing rows for the role and inserts the provided list
Update with rules key presentSame replace semantics
Update without rules keyExisting rules are left unchanged (syncRules(..., null) returns early)
rules: []Clears all rules for the role

Each item becomes a RoleRule row:

ColumnSource
role_idParent role
driver$rule['driver'] — must match an evaluator name()
payload$rule['payload'] ?? null (JSON)

Payload field validation is taken from each evaluator’s form() field validation strings (Role::validateRulesPayload).

See also RuleEvaluatorRegistry and RoleRuleResource.


Built-in Drivers

Name (name())ClassSummary
timeTimeEvaluatorCurrent time within fromto window
weekdayWeekdayEvaluatorCurrent weekday in allowed list
user_statusUserStatusEvaluatorPersonable attribute equals expected value
ip_rangeIpRangeEvaluatorRequest IP matches exact IP or IPv4 CIDR
locationLocationEvaluatorCountry / city from context or headers
envEnvEvaluatorApp environment in allowed list
role_countRoleCountEvaluatorActive membership count within min/max
quotaQuotaEvaluatorActive memberships in a collection ≤ limit
custom_expressionCustomExpressionEvaluatorSafe comparison over context/request keys

time — TimeEvaluator

Restricts the role to a daily time window. Supports overnight ranges when from > to.

Payload fields

FieldTypeRequiredDescription
fromstring (H:i)yesStart time
tostring (H:i)yesEnd time
timezonestringnoDefaults to config('app.timezone') or UTC

Behavior

  • Missing from or tofalse
  • Same-day window (from <= to): now must be between inclusive bounds
  • Overnight (from > to): now >= from or now <= to

Example

{
"driver": "time",
"payload": {
"from": "09:00",
"to": "17:30",
"timezone": "Asia/Tehran"
}
}

weekday — WeekdayEvaluator

Allows the role only on selected weekdays.

Payload fields

FieldTypeRequiredDescription
daysarray|CSV stringyesDay names (mondaysunday) and/or Carbon dayOfWeek numbers (06)
timezonestringnoDefaults to app timezone

Behavior

  • Empty daysfalse
  • Matches lowercase English day name or numeric day of week

Example

{
"driver": "weekday",
"payload": {
"days": ["monday", "wednesday", "friday"],
"timezone": "UTC"
}
}

user_status — UserStatusEvaluator

Compares an attribute on the personable context to an expected string.

Payload fields

FieldTypeRequiredDescription
attributestringnoAttribute name; default status
expectedstringyesExpected value (string comparison)

Behavior

  • Missing expectedfalse
  • Boolean attributes are normalized to '1' / '0'
  • Comparison is (string) $actual === $expected

Example

{
"driver": "user_status",
"payload": {
"attribute": "status",
"expected": "active"
}
}

ip_range — IpRangeEvaluator

Checks the current request IP against a list of exact addresses or IPv4 CIDR ranges.

Payload fields

FieldTypeRequiredDescription
rangesarray|CSV stringyese.g. 127.0.0.1, 10.0.0.0/8

Behavior

  • No request IP or empty ranges → false
  • First matching range wins
  • Non-CIDR entries require exact string match
  • CIDR matching is IPv4 only

Example

{
"driver": "ip_range",
"payload": {
"ranges": "127.0.0.1, 10.0.0.0/8, 192.168.1.0/24"
}
}

location — LocationEvaluator

Restricts by country and/or city. Values come from the personable context, then request headers.

Payload fields

FieldTypeRequiredDescription
countriesarray|CSVno*ISO-style country codes (compared uppercase)
citiesarray|CSVno*City names (compared lowercase)

* At least one of countries or cities must be non-empty, otherwise evaluation returns false.

Resolution order

DimensionSources (first non-empty)
Country$context->country, header CF-IPCountry, header X-Country
City$context->city, header X-City

Both configured dimensions must pass (AND). An empty list for a dimension means “do not constrain that dimension”.

Example

{
"driver": "location",
"payload": {
"countries": "IR,DE",
"cities": "tehran,berlin"
}
}

env — EnvEvaluator

Allows the role only when app()->environment() is in the allowed list.

Payload fields

FieldTypeRequiredDescription
environmentsarray|CSVyese.g. local, staging, production

Example

{
"driver": "env",
"payload": {
"environments": ["local", "staging"]
}
}

role_count — RoleCountEvaluator

Counts the personable’s non-expired memberships (optionally filtered by role type) and checks min/max bounds.

Payload fields

FieldTypeRequiredDescription
typestringnoLimit count to memberships whose role has this type
minintnoMinimum count (default 0)
maxintnoMaximum count (inclusive); omit for no upper bound

Behavior

  • Context must be an Eloquent Model; otherwise false
  • Memberships with expired_at null or in the future are counted

Example

{
"driver": "role_count",
"payload": {
"type": "tenant",
"min": 1,
"max": 3
}
}

quota — QuotaEvaluator

Ensures the personable’s active memberships in a given collection do not exceed a limit.

Payload fields

FieldTypeRequiredDescription
keystringyesMembership collection value to count
limitintyesMaximum allowed count (inclusive)

Behavior

  • Context must be a Model
  • Missing key or limitfalse
  • Counts non-expired memberships where collection = key
  • Passes when $count <= $limit

Example

{
"driver": "quota",
"payload": {
"key": "projects",
"limit": 5
}
}

custom_expression — CustomExpressionEvaluator

Safe comparisons over whitelisted context/request keys. Does not use eval.

Payload fields

FieldTypeRequiredDescription
left_keystringyesAttribute path; see resolution below
operatorstringyesOne of: eq, neq, in, not_in, gt, gte, lt, lte
right_valuemixedyesScalar or list (CSV for in / not_in)

left_key resolution

Key patternResolves to
request.ipCurrent request IP
request.*Value from request()->all() (after the request. prefix)
other safe pathcontextValue($context, $key) (model attribute / array key)

Safe keys must match /^[a-zA-Z_][a-zA-Z0-9_.]*$/.

Example

{
"driver": "custom_expression",
"payload": {
"left_key": "account_tier",
"operator": "in",
"right_value": "pro,enterprise"
}
}
{
"driver": "custom_expression",
"payload": {
"left_key": "request.ip",
"operator": "eq",
"right_value": "127.0.0.1"
}
}

Custom Evaluator

Extend AbstractRuleEvaluator, implement name(), evaluate(), and form(), then register the class.

namespace App\Rolix\Evaluators;

use JobMetric\CustomField\CustomFieldBuilder;
use JobMetric\Form\FormBuilder;
use JobMetric\Rolix\Contracts\AbstractRuleEvaluator;
use JobMetric\Rolix\Facades\RuleEvaluatorRegistry;

class BusinessHoursEvaluator extends AbstractRuleEvaluator
{
public function name(): string
{
return 'business_hours';
}

public function evaluate(array $rule, mixed $context): bool
{
$open = $this->string($rule, 'open') ?? '09:00';
$close = $this->string($rule, 'close') ?? '17:00';

// Reuse TimeEvaluator semantics or custom logic
return app(\JobMetric\Rolix\RuleEvaluators\TimeEvaluator::class)
->evaluate(['from' => $open, 'to' => $close], $context);
}

public function form(): FormBuilder
{
return $this->settingsForm(function ($tab) {
$tab->customField(function (CustomFieldBuilder $field) {
$field::time()
->name('open')
->label('Open')
->validation('required|date_format:H:i');
})->customField(function (CustomFieldBuilder $field) {
$field::time()
->name('close')
->label('Close')
->validation('required|date_format:H:i');
});
});
}
}

// In a service provider boot method:
RuleEvaluatorRegistry::register(BusinessHoursEvaluator::class);

After registration, attach rules with "driver": "business_hours". The registry keys evaluators by $instance->name().

UI form metadata

RuleEvaluatorRegistry::formFor('time');
// [
// 'driver' => TimeEvaluator::class,
// 'name' => 'time',
// 'form' => [...built FormBuilder array...],
// ]