›
›
›
  1. docs
  2. ›
  3. byrcsc/laravel-assignment
1.x
Browse documentationOpenClose

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Assignments and slots
  • Assigning and offering
  • Selection policies
  • Scopes and rotation
  • Profiles
  • The queue
  • Offer cascades

Operations

  • Expiry and scheduling
  • Reading assignments
  • Notifications
  • Events and listeners
  • Concurrency guarantees

Reference

  • Configuration
  • API reference
  • Console commands
  • Exceptions
  • Testing
  • Troubleshooting

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Assignments and slots
  • Assigning and offering
  • Selection policies
  • Scopes and rotation
  • Profiles
  • The queue
  • Offer cascades

Operations

  • Expiry and scheduling
  • Reading assignments
  • Notifications
  • Events and listeners
  • Concurrency guarantees

Reference

  • Configuration
  • API reference
  • Console commands
  • Exceptions
  • Testing
  • Troubleshooting

byrcsc/laravel-assignment · 1.x

Scopes and rotation.

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.

The default scope

Omit scope() and the package derives one from the assignable class and the role:

App\Models\Enquiry:
App\Models\Callout:crew

That 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.

Choosing a scope string

The scope decides who takes turns with whom. Pick the string that names the group that should share a rotation:

Scope stringRotation
trade:plumbingOne rotation per trade
suburb:richmondOne rotation per suburb
team:4:role:reviewerOne rotation per team and role
OmittedOne 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.

Rotation survives a changing candidate list

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:

  • Rebuilt lists. Running the same query again produces the same rotation.
  • Reordered lists. A different orderBy does not restart the rotation.
  • Grown lists. A new candidate joins the rotation in place.
  • Shrunk lists. When the last-picked candidate is gone, selection starts from the front.

Locking

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.

Writing state

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.

What it does not do

  • Expire or prune scope rows. A scope row lives until you delete it. There is no command for that, because a scope is one small row.
  • Namespace scopes per policy. Two policies using the same scope string share the same state. Prefix your keys, as the shipped policies do.
  • Migrate state when a scope string changes. Change the string and the new scope starts empty, which restarts the rotation.

What to read next

  • Selection policies for what each policy stores.
  • Concurrency guarantees for what the lock promises under load.
  • Configuration to rename the scopes table.
PreviousSelection policiesNextProfiles
View source

On this page

  1. The default scope
  2. Choosing a scope string
  3. Rotation survives a changing candidate list
  4. Locking
  5. Writing state
  6. What it does not do
  7. What to read next