›
byrcsc/laravel-assignment · 1.x
A scope string names where a stateful policy saves its state, and selection locks that row.
RoundRobin has to remember who it picked last. A scope is the string that
names that saved state. 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:crewThe default gives one global rotation per model and role. That rotation is enough until you need to split work by trade, territory, or team.
The class part is the fully qualified class name, even when you enforce a morph
map. A morph alias changes the value in assignable_type, not the derived
scope string.
The package writes the scope that a selection used to the assignment's own
scope column. You can later see 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 |
The package creates a scope row 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.
Storing the identity keeps the rotation correct 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.
The lock stops a cursor from skipping or repeating a turn under concurrency. The lock also means that 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 different scopes run independently only on MySQL and PostgreSQL. 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 transaction rollback
discards the state.
Keep the value small. The package stores it as JSON in one column and reads it on every selection in that scope.