byrcsc/laravel-approval · 1.x
Eligibility and authorization.
Ask whether an actor may decide, withdraw, or delegate, read the refusal reason, and use or extend the shipped Laravel policy.
Eligibility is a read. It answers the same question for a disabled button as for a decision about to be recorded, using one calculation, so the two can never disagree.
$eligibility = Approval::eligibility($request, $user);
if ($eligibility->eligible) {
Approval::approve($request, $user);
}What the result carries
$eligibility->eligible; // bool
$eligibility->reason; // null, or an Ineligibility enum
$eligibility->stage; // the active stage, when the check got that far
$eligibility->stageProgress; // its progress, same condition
$eligibility->assignment; // the actor's assignment, when grantedThe stage, its progress, and the assignment are included so a view can render a control, a reason for a disabled control, and the stage state from one call without repeating the same queries.
Refusal reasons
use ByRcsc\LaravelApproval\Enums\Ineligibility;| Reason | Meaning |
|---|---|
RequestClosed | The request is in any state other than pending |
NoActiveStage | No stage is currently waiting |
NotAnApprover | This actor has no assignment on the active stage |
AssignmentDelegated | Their assignment now belongs to a delegate |
DecisionAlreadyRecorded | A decision has already been recorded for their assignment |
SelfApproval | They submitted it, and this workflow does not allow that |
The order follows the check itself, from request state through to the active assignment. Each maps to the exception a decision would throw — see recording decisions.
RequestClosed covers conflicted too, which is the one that surprises
people: a conflicted request is still unresolved, so
the record cannot be submitted again, but it is no longer pending and nobody
can decide it. It needs a conflict resolution, not an approval.
The short forms
Approval::canApprove($request, $user); // bool
$request->canBeApprovedBy($user); // the same, from the request
$order->canBeApprovedBy($user); // via the model's open request$order->canBeApprovedBy() returns false both for "not this user's stage" and
for "no unresolved request". $order->approvalEligibility($user) distinguishes
them by returning null in the second case.
Withdrawal and delegation
Both are request-level questions, so ask them on the request:
$request->canBeWithdrawnBy($requester);
$request->canBeDelegatedBy($approver, $deputy);
Approval::withdrawalEligibility($request, $actor);
Approval::delegationEligibility($request, $actor, $delegate);Withdrawal has two refusal reasons, RequestClosed and NotRequester.
Delegation has nine — see
delegation and reassignment.
The shipped policy
ApprovalRequestPolicy follows Laravel's discovery convention, so nothing
needs registering:
$user->can('approve', $request);
$this->authorize('withdraw', $request);| Ability | Rule |
|---|---|
view | The actor is involved in the request |
approve | Approval::canApprove() |
reject | Approval::canApprove() |
returnForRevision | Approval::canApprove() |
withdraw | Approval::canWithdraw() |
delegate | Approval::canDelegate() |
cancel | Denied — host-owned |
The three decisions share one gate because all three are the same assignment's decision.
view is deliberately wider
$request->involves($user);True when the actor requested it, or is named on any of its assignments — including as a delegate, and including stages already decided. An approver who decided stage one last week is still a participant, which is what a read authorization check needs to know. Reading a decision is not making one.
Applications needing a wider audience, such as auditors or department heads, should widen it in their own policy.
cancel denies by default
Administrative cancellation is host-owned. Inheriting requester authority for it would be wrong, so the shipped policy returns false and your application grants it.
Extending the policy
Extend rather than replace, so your rules layer on top of the package's rather than substituting for them:
use ByRcsc\LaravelApproval\Policies\ApprovalRequestPolicy as BasePolicy;
final class RequestPolicy extends BasePolicy
{
public function view(Model $user, ApprovalRequest $request): bool
{
return parent::view($user, $request) || $user->isAuditor();
}
public function cancel(Model $user, ApprovalRequest $request): bool
{
return $user->hasRole('approval-administrator');
}
}Register it the ordinary way, which takes precedence over discovery:
Gate::policy(ApprovalRequest::class, RequestPolicy::class);The engine checks anyway
The eligibility check is the floor, not the whole authorization story. The engine runs it inside every decision regardless of what a gate said, because the approver list is its own data and nothing outside it is in a position to check.
Host policies and gates sit on top. A gate that passes cannot make an unassigned actor's approval succeed.
Administrative cancellation
Approval::cancel($request, $administrator, 'Duplicate of PO-1002');Requires an attributable actor and no eligibility check of its own — the
authorization is your policy's cancel ability, which denies by default. The
request state becomes cancelled, and ApprovalCancelled fires with the actor
and comment.
Cancellation and withdrawal both close a pending request. Withdrawal is the requester's; cancellation is somebody else's.