NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Approvals for Laravel

Actions & DTOs

The actions in RoundlyConsulting\Approvals\Actions are the code the facade and the manager run — single-purpose classes with execute() for composing into your own actions, jobs and commands. The facade-to-action map is under DI and actions.

Action signatures

use RoundlyConsulting\Approvals\Actions\ApproveAction;
use RoundlyConsulting\Approvals\Actions\CancelApprovalAction;
use RoundlyConsulting\Approvals\Actions\CloseApprovalRequestAction;
use RoundlyConsulting\Approvals\Actions\ExpireApprovalsAction;
use RoundlyConsulting\Approvals\Actions\RejectAction;
use RoundlyConsulting\Approvals\Actions\RequestApprovalAction;
use RoundlyConsulting\Approvals\Actions\ToggleApprovalAction;
use RoundlyConsulting\Approvals\DataTransferObjects\DecisionData;
use RoundlyConsulting\Approvals\Enums\ApprovalStatus;

app(ApproveAction::class)->execute($user, $post, DecisionData::approved('Looks good'));
app(RejectAction::class)->execute($user, $post, DecisionData::rejected('Needs work'));
app(RequestApprovalAction::class)->execute($user, $post);                    // pending Approval
app(RequestApprovalAction::class)->execute($user, $post, new DecisionData(ApprovalStatus::Pending, 'Check the sources', now()->addDay()));
app(CancelApprovalAction::class)->execute($user, $post, 'Changed my mind');   // ?Approval
app(ToggleApprovalAction::class)->execute($user, $post);                     // bool
app(ToggleApprovalAction::class)->execute($lead, $release, DecisionData::approved('LGTM'), $request);
app(CloseApprovalRequestAction::class)->execute($purchase);                  // int — rounds closed, as cancelled
app(CloseApprovalRequestAction::class)->execute($purchase, ApprovalStatus::Expired, $round); // only $round
app(ExpireApprovalsAction::class)->execute();                                // int
app(ExpireApprovalsAction::class)->execute(subjectType: Post::class);        // int — posts only
Actionexecute()Notes
ApproveAction($actor, $approvable, ?DecisionData, ?ApprovalRequest): ApprovalGate check, named approvers, delegation, weight and stage; supersedes a held rejection; a repeat is a no-op; resolves the request.
RejectAction($actor, $approvable, ?DecisionData, ?ApprovalRequest): ApprovalGate check, named approvers, delegation, weight and stage; supersedes a held approval; reads no expiry — a rejection has none; resolves the request.
RequestApprovalAction($actor, $approvable, ?DecisionData, ?ApprovalRequest): ApprovalRecords a pending decision with the DecisionData reason and expiry (its reply-by deadline) — or returns the actor’s live one unchanged; fires ApprovalRequested. It ignores the DTO’s status and weight: an ask always carries weight 1, silently — only the facade refuses weight().
CancelApprovalAction($actor, $approvable, ?string $reason, ?ApprovalRequest): ?ApprovalWithdraws the actor’s live decision, or one it made as a delegate while the delegation is still in force; on a staged request only in the open stage. $reason replaces the decision’s; null keeps it.
ToggleApprovalAction($actor, $approvable, ?DecisionData, ?ApprovalRequest): boolApproves through ApproveAction with the DecisionData and request, or withdraws and soft-deletes the live approval with the DTO’s reason, firing ApprovalCancelled like CancelApprovalAction. The live approval decides the direction, not the DTO’s status.
OpenApprovalRequestAction(ApprovalRequestData $data): ApprovalRequestOpens a flat request and stores its named approvers; required_approvers defaults to one per named approver.
RequestStagedApprovalAction($subject, $stages, $rejectOnStageRejection = true, ?$expiresAt, ?$workflow): ApprovalRequestOpens a staged pipeline; each stage stores its named approvers.
OpenWorkflowRequestAction($subject, WorkflowPreset, $approvers, ?$expiresAt): ApprovalRequestOpens a request from a resolved preset; $expiresAt replaces the preset’s expiry for this round, null keeps it.
DelegateApprovalsAction($delegator, $delegate, ?$startsAt, ?$endsAt): ApprovalDelegationValidates and saves a delegation; fires ApprovalDelegated.
RevokeApprovalDelegationAction($delegator, ?$delegate): intRevokes active and scheduled delegations; returns the count.
ExpireApprovalsAction(?$now, ?$subjectType): intLapses overdue asks, approvals and requests — optionally of one subject type; returns the count.
CloseApprovalRequestAction($subject, ApprovalStatus $outcome = Cancelled, ?ApprovalRequest): intCloses the subject’s open rounds — or only the given one — as cancelled or expired, with no actor and no gate check; returns how many it closed.

Attaching to a specific request

The approve, reject, ask, cancel and toggle actions accept the request to attach to as their fourth argument — the facade passes it with within(). Without it, they use the approvable’s latest open request, or record a standalone decision when the approvable never had one. A request of another subject throws InvalidApprovalRequestException and a closed one ClosedApprovalRequestException, before anything is written. CloseApprovalRequestAction takes the request as its third argument and closes only that round:

use RoundlyConsulting\Approvals\Actions\ApproveAction;
use RoundlyConsulting\Approvals\DataTransferObjects\DecisionData;
use RoundlyConsulting\Approvals\Facades\Approvals;

$request = Approvals::request($release)->from([$lead, $qa])->open();

// The action's fourth argument is what within($request) passes on the facade.
app(ApproveAction::class)->execute($lead, $release, DecisionData::approved('Ship it'), $request);

DecisionData

DecisionData carries the intent of a decision. Build it with a named constructor:

ConstructorResult
DecisionData::approved(?$reason, ?$expiresAt, ?$weight)An approval — weight overrides the resolved weight for this one decision.
DecisionData::rejected(?$reason, ?$weight)A rejection — weight works as for approved().
DecisionData::pending(?$expiresAt)A pending decision. For an ask with a reason use new DecisionData(ApprovalStatus::Pending, $reason, $expiresAt).
DecisionData::cancelled(?$reason)A cancellation.

RequestApprovalAction writes the DTO’s reason as the ask’s reason and its expiry as the reply-by deadline, but ignores its status and weight: it always records a pending decision with weight 1, and a weight set on the DTO is dropped without an error — only the facade refuses weight() before ask(). Set the weight on the decision that answers the ask.

Staged requests with a workflow name

RequestStagedApprovalAction accepts an expiry and a workflow name, which it stamps on the request — the facade and the trait set the expiry, the action also takes the name:

use RoundlyConsulting\Approvals\Actions\RequestStagedApprovalAction;
use RoundlyConsulting\Approvals\DataTransferObjects\StageDefinition;
use RoundlyConsulting\Approvals\Enums\ApprovalRule;

app(RequestStagedApprovalAction::class)->execute(
    $release,
    [
        new StageDefinition([$eng1, $eng2], name: 'engineering'),
        new StageDefinition([$product], ApprovalRule::Any, name: 'product'),
    ],
    rejectOnStageRejection: true,
    expiresAt: now()->addDays(3),
);

ApprovalRequestData

ApprovalRequestData is the read-only input of OpenApprovalRequestAction: subject, approvers (the named approvers — none named means any approver may decide), rule, quorum, expiresAt, plus an optional requiredApprovers (defaults to one per named approver) and workflow name.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.