Browse documentationOpen

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 granted

The 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;
ReasonMeaning
RequestClosedThe request is in any state other than pending
NoActiveStageNo stage is currently waiting
NotAnApproverThis actor has no assignment on the active stage
AssignmentDelegatedTheir assignment now belongs to a delegate
DecisionAlreadyRecordedA decision has already been recorded for their assignment
SelfApprovalThey 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);
AbilityRule
viewThe actor is involved in the request
approveApproval::canApprove()
rejectApproval::canApprove()
returnForRevisionApproval::canApprove()
withdrawApproval::canWithdraw()
delegateApproval::canDelegate()
cancelDenied — 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.