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

The default scope

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

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

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

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

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.

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.

Storing the identity keeps the rotation correct 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.

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.

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

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