›
byrcsc/laravel-assignment · 1.x
A scope is the string a stateful policy keys its memory on, and it is locked during selection.
RoundRobin has to remember who it picked last. A scope is the string that
memory is filed under. Two scopes rotate independently, and selections inside
one scope happen one at a time.
Assignment::for($enquiry)
->among($tradies)
->scope('trade:'.$enquiry->trade)
->using(RoundRobin::class)
->assign();Every plumbing enquiry shares a cursor and takes turns. Electrical has its own cursor and is unaffected.
Omit scope() and the package derives one from the assignable class and the
role:
App\Models\Enquiry:
App\Models\Callout:crewThat default gives one global rotation per model and role for free, which is usually what you want until trades, territories, or teams appear.
The class part is the fully qualified class name even when a morph map is
enforced. A morph alias changes what lands in assignable_type, not the
derived scope string.
Whichever scope a selection resolved is written to the assignment's own scope
column, so you can see after the fact which rotation produced a row.
The scope decides who takes turns with whom. Pick the string that names the group that should share a rotation:
| Scope string | Rotation |
|---|---|
trade:plumbing | One rotation per trade |
suburb:richmond | One rotation per suburb |
team:4:role:reviewer | One rotation per team and role |
| Omitted | One rotation per assignable class and role |
Scopes are created on first use. There is no registration step, and no cleanup is required, because a scope row is a single small record.
Stateless policies ignore the scope entirely. LeastWorkload, Random, and
FirstAvailable still take the lock, but store nothing.
RoundRobin stores the identity of the assignee it picked, not an index. On
the next call it looks that assignee up in the list you passed and takes the
one after it.
That makes the rotation behave in the cases that break an index-based cursor:
orderBy does not restart the rotation.Selection opens a transaction, takes a row lock on the scope, resolves the candidates, runs the policy, writes the assignment, and saves the state. Two selections in the same scope therefore run one after the other, and two selections in different scopes do not block each other.
This is what stops a cursor from skipping or repeating a turn under concurrency. It also means a scope string shared by every assignment in the application serializes every assignment in the application. Scope by the group that should take turns, not by convenience.
SQLite serializes writes anyway. On SQLite the database itself allows one writer at a time, so the independence of different scopes is a MySQL and PostgreSQL property. The correctness guarantee holds on all three.
If the selected candidate's slot turns out to be taken, the whole transaction rolls back, including the cursor. The next call sees the cursor as it was.
ScopeState is a small key-value store on top of the scope row:
public function select(Model $assignable, Collection $candidates, ScopeState $state): ?Model
{
$seen = $state->get('seen', []);
$selected = $candidates->reject(
fn (Model $candidate) => in_array($candidate->getKey(), $seen, true),
)->first() ?? $candidates->first();
$state->put('seen', [...$seen, $selected->getKey()]);
return $selected;
}get() and put() accept dotted keys and any JSON-serializable value. The
engine calls persist() after a successful write, so a policy does not need to
save anything itself. When the assignment fails, the state is discarded with
the transaction.
Keep the value small. It is stored as JSON in one column and read on every selection in that scope.