Browse documentationOpen

byrcsc/laravel-approval · 1.x

Bulk decisions.

Approve or reject many requests in one call with per-request transactions, and inspect the successful and failed outcomes.

Bulk operations use one naming convention, the Many suffix:

use ByRcsc\LaravelApproval\Facades\Approval;

$result = Approval::approveMany($requests, $financeUser);
$result = Approval::rejectMany($requests, $financeUser, 'Over budget');

Each request is decided in its own transaction, so one failure does not roll back the rest. A bulk approval over an approver's inbox is the common case, and one request that somebody else already decided should not undo the other forty.

Reading the result

$result->outcomes;      // list<BulkDecisionOutcome>, in input order
$result->successful();  // only the ones that went through
$result->failed();      // only the ones that did not

Each outcome carries the request it was built from, and either the resulting request or the exception that stopped it:

foreach ($result->failed() as $outcome) {
    report($outcome->exception);

    logger()->warning('Bulk approval skipped a request', [
        'request' => $outcome->request->ulid,
        'reason' => $outcome->exception::class,
    ]);
}

$outcome->successful() is the per-item boolean.

Bulk methods never throw for an individual request. A caller that wants all-or-nothing should wrap the call in its own transaction and inspect failed() before committing.

A different decision per request

decideMany() is the advanced form. The callback receives each request and returns the decision to record for it:

use ByRcsc\LaravelApproval\Data\ApprovalDecision;

$result = Approval::decideMany($requests, function ($request) use ($user, $limits) {
    return $request->approvable->amount <= $limits->autoApproveUnder
        ? ApprovalDecision::approve($user, 'Under the delegated limit')
        : ApprovalDecision::return($user, 'Exceeds the delegated limit');
});

approveMany() and rejectMany() are thin wrappers over it that build the same decision for every request.

An exception thrown by the callback itself is captured as that request's failure, the same as one thrown by the engine.

Driving it from the inbox

$requests = Approval::awaiting($user)
    ->whereHas('approvable', fn ($query) => $query->where('supplier_id', $supplier->id))
    ->get();

$result = Approval::approveMany($requests, $user, 'Bulk approved for a trusted supplier');

awaiting() excludes requests the user has already approved. A rejection they recorded on a stage that is still pending does not exclude it, so a bulk decision can still hit DecisionAlreadyRecorded — which lands in failed() rather than throwing. Filter further with any ordinary Eloquent constraint before calling.

Comments and metadata

approveMany() takes an optional comment; rejectMany() requires one, exactly as the single-request forms do. Both accept trailing metadata recorded on every resulting audit action:

Approval::approveMany($requests, $user, 'Quarterly clearance', [
    'batch' => $batchId,
]);

The batch identifier is then visible on each request's history, which is what lets a later audit reconstruct which decisions were made together.

Events

Bulk decisions fire the same events as single ones, per request, inside that request's transaction. Nothing bulk-specific is dispatched, and a queued listener that dispatches after commit sees each request commit individually.

Testing

Approval::fake() records a bulk call as one interaction:

$fake = Approval::fake();

Approval::approveMany($requests, $user);

$fake->assertApprovedMany();
$fake->assertApprovedMany(fn ($recorded) => count($recorded->target) === 3);

assertRejectedMany() is the rejection counterpart. The recorded interaction's target is the list of requests. See testing.