›
byrcsc/laravel-assignment · 1.x
Two verbs create assignments, and only the assignee an offer names can answer it.
assign() writes an active assignment straight away. offer() writes one the
assignee has to accept or decline. Both hold the slot, and both refuse to fill
a slot that is already occupied.
Reach for assign() when the decision is the application's, and for offer()
when the assignee gets a say: a crew who can be busy, a contractor who can
turn work down.
$assignment = $enquiry->assign($tradie);
$assignment = $callout->assign($crew, role: 'crew');
$assignment = $enquiry->assign($tradie, by: $manager);The assignment is active from the first moment. The optional by argument is
any model, and it is recorded in the polymorphic assigned_by columns. Leave it
off and assigned_by stays null, which means the engine did it.
Assigning into an occupied slot throws SlotOccupied, so filling a slot is
never accidental.
$offer = $callout->offer($crew, role: 'crew', ttlSeconds: 120);The row is offered, offered_at is stamped, and expires_at is offered_at
plus the TTL. Leave ttlSeconds off and the offer never expires on its own: it
stands until somebody answers it.
While the offer stands it holds the slot. Another assign() or offer() on
that slot throws.
The answer goes through the assignee, not through the assignment, because only the assignee an offer names may answer it:
$crew->accept($offer); // offered becomes active, accepted_at is stamped
$crew->decline($offer); // the row ends with reason declinedBoth are checked against the row as it is in the database at that moment, under a lock:
| Situation | Result |
|---|---|
| A different model answers | NotTheAssignee thrown |
| The offer was already accepted | OfferNotOpen thrown |
| The offer was already declined | OfferNotOpen thrown |
expires_at has passed | OfferNotOpen thrown |
An accept racing a decline resolves to exactly one winner. The loser gets
OfferNotOpen.
Accepting dispatches AssignmentAccepted and then Assigned, so a listener
that reacts to work becoming active does not need to know whether it arrived
through an offer or through assign().
$enquiry->unassign(); // ends with reason unassigned
$callout->unassign(role: 'crew');
$assignment->complete(); // ends with reason completed
$assignment->complete(by: $manager);unassign() reads the open row for that slot and ends it. There is nothing to
end when the slot is empty, which throws NotAssigned.
complete() is for work that finished rather than work taken away. It applies
only to an active assignment; calling it on an offered or already ended row
throws InvalidTransition.
Replacing a holder is its own verb, so it cannot happen by accident:
$callout->reassign($otherCrew, role: 'crew');Reassignment is atomic. Inside one transaction the open row ends with reason
reassigned and the replacement row is created. If the replacement cannot be
written, the old row is not ended.
Two things it refuses:
NotAssigned.AlreadyAssigned.Reassignment works on an open offer as well as an active assignment, so you can take back an offer nobody has answered yet.
Reassignment fires Reassigned, carrying both rows:
public function handle(Reassigned $event): void
{
$event->oldAssignment; // the row that just ended
$event->newAssignment; // the row that replaced it
}Every lifecycle event implements ShouldDispatchAfterCommit. Inside a
transaction that later rolls back, no event is dispatched, so a listener never
sees an assignment that does not exist. See events.