Rate Engine Registry
Contract for registering and resolving calculation strategies and rate modifiers in the rate engine.
Overview
App\Services\RateEngine\RateEngineRegistry is the composition root for the pricing engine's extension points. It stores:
CalculationStrategyimplementations, keyed by strategy identifierRateModifierimplementations, keyed by modifier identifier
Core binds the registry as a singleton in AppServiceProvider and registers the built-in strategies and modifiers during boot.
Public surface
The registry exposes two symmetric registration and lookup APIs:
| Area | Methods | Notes |
|---|---|---|
| Strategies | registerStrategy(), hasStrategy(), strategy(), strategies() |
strategy() throws if the identifier is missing |
| Modifiers | registerModifier(), hasModifier(), modifier(), modifiers() |
modifiers() always returns ascending priority() order |
| Configuration | composeSections(), configRules(), sanitiseStrategyConfig(), sanitiseModifierConfigs() |
Compose, validate, and sanitise strategy and enabled-modifier configuration through their registered schemas |
The accepted strategy contract is App\Contracts\CalculationStrategy. It requires:
identifier(): stringlabel(): stringallowedBasePeriods(): arraysupportsMultiplier(): boolsupportsFactor(): boolconfigSchema(): Schemacalculate(CalculationContext $context): RateBreakdown
The accepted modifier contract is App\Contracts\RateModifier. It requires:
identifier(): stringlabel(): stringpriority(): intconfigSchema(): Schemaapply(RateBreakdown $breakdown, array $config, CalculationContext $context): RateBreakdown
Both contracts are explicitly documented in code as deterministic, side-effect-free functions. They operate only on plain configuration and a CalculationContext; they must not perform database access or external I/O.
The singleton registry retains the same registered strategy and modifier object instances across metadata, validation, sanitisation, and calculation calls. It does not resolve fresh implementations from the flightcase per invocation.
Current core registration shape
Today the framework registers the rate engine in AppServiceProvider like this:
$registry = new RateEngineRegistry;
$registry->registerStrategy(new PeriodStrategy);
$registry->registerStrategy(new FixedStrategy);
$registry->registerStrategy(new HybridStrategy);
$registry->registerModifier(new MultiplierModifier);
$registry->registerModifier(new FactorModifier);
That is the current shipped shape.
Built-in strategies
The current core strategies are:
| Identifier | Label | Base periods | Multiplier | Factor |
|---|---|---|---|---|
period |
Period-based | Half-Hourly, Hourly, Daily, Weekly, Monthly | Yes | Yes |
fixed |
Fixed | None | No | Yes |
hybrid |
Hybrid | Daily, Weekly, Monthly | No | No |
The prototype-only usage strategy is intentionally not shipped and is not registered here.
Built-in modifiers
| Identifier | Label | Priority | Behaviour |
|---|---|---|---|
multiplier |
Multiplier | 100 |
Rewrites per-period line items using configured duration tiers |
factor |
Factor | 200 |
Scales the post-multiplier per-unit subtotal using quantity ranges |
Lower priorities run first. The core ordering therefore guarantees multiplier-before-factor execution.
Invariants and failure modes
Identifier uniqueness
The registry keeps one active entry per identifier in each of its two associative maps. Registering a second strategy or modifier with an existing identifier replaces the active object; it does not accumulate another pipeline entry or reject the duplicate. Replacing a key preserves its original map position.
Lookup failures
strategy($identifier)throwsInvalidArgumentExceptionwhen the strategy is missingmodifier($identifier)throwsInvalidArgumentExceptionwhen the modifier is missing
Callers that need a guard should use hasStrategy() or hasModifier() first.
The list-driven low-level methods have a different boundary. composeSections(), configRules(), sanitiseModifierConfigs(), and RateCalculator iterate registered modifiers and test them against the enabled identifier list, so unknown identifiers in the enabled list are ignored. They do not call modifier() for each requested identifier.
Rate-definition writes add validation above that low-level behavior. ValidatesRateConfig rejects each unregistered enabled modifier with Unknown modifier [{id}]. before create or update sanitisation. RateEngineMetaController::schema() likewise restricts modifier query values to registered identifiers. Callers should not use the low-level ignore behavior as input validation.
Ordering
strategies() returns the warehoused map keyed by identifier. modifiers() returns a list sorted by ascending priority(). Equal-priority modifiers retain registration order; registration order otherwise does not override a priority difference.
Configuration schema contract
The registry is also responsible for the configuration contract used by the rate-specification UI and API:
composeSections($strategyId, $enabledModifierIds)builds the ordered form sectionsconfigRules(...)builds Laravel validation rules understrategy_config.*andmodifier_configs.{id}.*sanitiseStrategyConfig(...)keeps only visible strategy fields and casts themsanitiseModifierConfigs(...)keeps only enabled modifiers and sanitises each config against its schema
Current rate-specification writes constrain calculation_strategy to the built-in CalculationStrategyType enum. Registering another strategy makes it available through the low-level registry and metadata list, but does not extend that enum-backed create or update input contract.
period and hybrid definitions require a base period from their allowed sets. fixed accepts no base period, and the write actions clear one when changing a definition to fixed. Enabled modifiers must be registered; multiplier and factor must also be supported by the chosen strategy. Additional registered modifier identifiers are not constrained by those two core compatibility flags.
This is how the current built-ins shape configuration:
Strategy config
period and hybrid share the time-option fields:
day_typebusiness_hours_startbusiness_hours_endrental_days_per_weekleeway_minutesfirst_day_cutofflast_day_cutoff
fixed has an empty schema.
hybrid adds:
fixed_chargefixed_period_units
Hidden fields are stripped during sanitisation. For example, business-hours fields are removed when day_type is not business.
Modifier config
multiplier stores:
[
'tiers' => [
['multiplier' => '1.0'],
['multiplier' => '0.7'],
],
]
factor stores:
[
'ranges' => [
['from' => 1, 'to' => 5, 'factor' => '1.0'],
['from' => 6, 'to' => null, 'factor' => '0.9'],
],
]
Disabled modifier configs are dropped entirely during sanitisation.
Schema validation and sanitisation are separate steps. Validation applies rules only for visible strategy fields and enabled registered modifiers. Sanitisation then retains only schema-declared visible fields that were supplied, casts their values, and removes disabled or unregistered modifier configs before persistence.
Runtime consumers
RateEngineMetaControllerreads strategy and modifier metadata and composes configuration sections for API clients.ValidatesRateConfigsupplies structural compatibility checks and schema rules to the rate-specification write actions.CreateRateSpecificationandUpdateRateSpecificationsanitise configuration immediately before persistence.RateCalculatorresolves the selected strategy and applies enabled registered modifiers during pricing calculations.
Calculation pipeline
App\Services\RateEngine\RateCalculator consumes the registry in two stages:
- Resolve the selected strategy and calculate the base
RateBreakdown - Iterate enabled modifiers in registry priority order and apply each one
That means the shipped pipeline is always:
strategy -> multiplier -> factor
when both modifiers are enabled. If only one modifier is enabled, only that modifier runs.
Worked example
With the current core registration, this call is valid:
$breakdown = app(RateCalculator::class)->calculate(
context: $context,
strategy: 'period',
enabledModifiers: ['multiplier', 'factor'],
modifierConfigs: [
'multiplier' => [
'tiers' => [
['multiplier' => '1.0'],
['multiplier' => '1.0'],
['multiplier' => '0.7'],
['multiplier' => '0.5'],
],
],
'factor' => [
'ranges' => [
['from' => 1, 'to' => 5, 'factor' => '1.0'],
['from' => 6, 'to' => 20, 'factor' => '0.9'],
['from' => 21, 'to' => null, 'factor' => '0.8'],
],
],
],
);
In the current tests, that produces:
- a
periodbase breakdown - then a multiplier-adjusted per-unit subtotal
- then a factor-adjusted subtotal based on quantity
The final result is deterministic because both the strategy and modifier contracts are pure and the modifier ordering is fixed.