›
byrcsc/laravel-assignment · 1.x
You pass the candidates, a policy picks one of them, and four policies ship with the package.
A policy answers one question: given these candidates, which one gets the work. It never asks whether a candidate should have been in the list.
use ByRcsc\LaravelAssignment\Facades\Assignment;
use ByRcsc\LaravelAssignment\Policies\RoundRobin;
Assignment::for($enquiry)
->among(Tradie::query()->where('available', true)->get())
->using(RoundRobin::class)
->assign();| Policy | Picks | State |
|---|---|---|
RoundRobin | The next candidate after the last one this scope took | Cursor per scope |
LeastWorkload | The candidate with the fewest open assignments | None |
Random | Any candidate | None |
FirstAvailable | The first candidate in the order you passed them | None |
| Any closure | Whatever you return | Yours |
using() takes a class name, an instance, or a closure. A class name is
resolved through the container, so a policy of your own can take constructor
dependencies.
Rotation is anchored to the last assignee this scope picked, not to a position in the array. The policy finds that assignee in the current list and takes the next one, wrapping at the end. When the previous assignee is no longer in the list, it starts from the front.
That anchoring is what makes rotation survive a candidate list you rebuild on every call. Reordering, adding, and removing candidates between calls all behave. See scopes and rotation.
Workload is the count of open assignments: active rows plus offers nobody has answered. Counting offers is deliberate, so ten pending offers cannot pile onto one idle crew.
use ByRcsc\LaravelAssignment\Policies\LeastWorkload;
->using(LeastWorkload::class) // every open assignment counts
->using(LeastWorkload::of(Callout::class)) // only callouts countof() constrains the count to one assignable type, so a crew's open support
tickets do not make them look busy for callouts.
Ties break toward the candidate assigned least recently, and a candidate who has never been assigned wins over one who has. Equal workloads therefore still rotate rather than always landing on the same person.
The policy issues a fixed number of queries whatever the size of the candidate list: one grouped count and one grouped maximum, both constrained to the candidates you passed.
Takes the first candidate in the order you passed them, which makes your query
order the priority order. Senior tradies first is orderBy('grade') and nothing
else.
Picks uniformly at random using random_int().
A closure receives the assignable and the candidates, and returns one candidate or null:
->using(fn ($enquiry, $candidates) => $candidates->sortByDesc('rating')->first())Returning null selects nobody, and the call returns null rather than throwing. That is a useful way to say "none of these are good enough right now" and let the queue hold the work.
Returning a model that is not in the candidate list is an error, and raises an
UnexpectedValueException. A policy cannot smuggle in an assignee the
application did not offer.
use ByRcsc\LaravelAssignment\Contracts\SelectionPolicy;
use ByRcsc\LaravelAssignment\ScopeState;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
final class HighestRated implements SelectionPolicy
{
public function select(Model $assignable, Collection $candidates, ScopeState $state): ?Model
{
return $candidates->sortByDesc('rating')->first();
}
}The third argument is the scope's own key-value store, row locked for the duration of the selection. Use it when your policy needs to remember something between calls. See scopes and rotation.
The list deliberately contains no territory policy and no trade policy. Who can take an assignment is a query on data your application already owns. Who gets it, among those, is what a policy decides.
Routing by suburb is therefore a query plus a scope, not a feature:
Assignment::for($callout)
->among(Crew::query()
->where('suburb', $callout->suburb)
->where('available', true)
->get())
->role('crew')
->scope('suburb:'.$callout->suburb)
->using(LeastWorkload::class)
->assign();Per-assignee capacity caps work the same way. Filter by
$crew->workload() < 3 before passing the candidates in, and no policy needs
to know the cap exists.
SlotOccupied, and any rotation cursor
moved during that attempt is rolled back with the transaction.LeastWorkload reads counts
and last-assigned timestamps. Anything richer belongs in a closure or a policy
class of your own.