›
byrcsc/laravel-approval · 1.x
Create a request after resolving its workflow, requester, draft, and approvers.
Submission is explicit. The package does not intercept saves to decide that something needs approval. You call the submit method yourself.
$request = $order->submitForApproval(
workflow: 'purchase-order',
newAttributes: ['amount' => 10_000],
);The facade takes the same call with the model first:
use ByRcsc\LaravelApproval\Facades\Approval;
$request = Approval::submit($order, 'purchase-order', ['amount' => 10_000]);$model->submitForApproval(...) and the guard escape hatch
withoutApprovalGuard() are
the only model-side aliases the package ships. Every other command lives on
the facade.
Approval::submit(
Model $approvable,
ApprovalWorkflow|WorkflowDefinition|WorkflowBuilder|string|null $workflow = null,
?array $newAttributes = null,
?Model $requester = null,
?string $comment = null,
array $metadata = [],
): ApprovalRequest;| Argument | Notes |
|---|---|
$approvable | Must already be persisted |
$workflow | A slug, a workflow model, an inline definition, or null |
$newAttributes | A draft, or null to submit the record itself |
$requester | Null to resolve it |
$comment | Recorded on the submitted audit action |
$metadata | Recorded on the same action. The engine adds the workflow slug |
Passing null for $newAttributes creates a record-level request. The
already-saved record is what needs approval, and the package applies nothing
when the request completes.
Passing an array creates a draft request. The request holds those values, and the package writes them to the record only after the final approval. See attribute drafts.
An empty array is neither, and raises InvalidDraftException::emptyDraft().
The package treats null and [] differently on purpose. Null means "approve
the record". An empty array is almost always a bug.
A workflow you name in the $workflow argument always wins over the other
sources. The package does not skip it when it is inactive. Instead, submission
throws InvalidWorkflowException.
When you name no workflow, the package gathers candidates from the model's
approvalWorkflow() method, the approval.workflows config map, and every
workflow whose approvable_type column names the model, from most specific to
least:
// config/approval.php
'workflows' => [
App\Models\PurchaseOrder::class => 'purchase-order',
],The package then filters the candidates. It drops an inactive workflow and a
workflow whose condition rules it out for this submission. It orders the rest
by source and then by priority, and uses the first. When no candidate remains,
submission throws WorkflowNotFoundException.
That is the short version, and it is all you need while a model has one workflow. Choosing the workflow covers registering several against one model, writing conditions, and how ties are refused.
A one-off approval does not need a stored workflow. Pass a WorkflowBuilder or
a WorkflowDefinition, and the request runs under it inline:
use ByRcsc\LaravelApproval\Definitions\StageBuilder;
use ByRcsc\LaravelApproval\Definitions\WorkflowBuilder;
$order->submitForApproval(
WorkflowBuilder::make('one-off-purchase')
->stage('Finance', fn (StageBuilder $stage) => $stage->approver($financeUser)),
['amount' => 10_000],
);The definition uses the same API as workflow
definitions. Submission validates and snapshots it like
a stored workflow, and the request records no workflow_id.
Approval::workflowFor() and Approval::reviseAndResubmit() accept a
definition the same way.
An inline definition cannot carry settings. The engine reads settings such as
allow_self_approval from the stored workflow when a decision is made, and an
inline workflow has no row to read them from. Passing settings throws
InvalidWorkflowDefinitionException. Store the workflow with approval:sync
when you need them.
The package looks for the requester in this order:
$requester argument.approvalRequester() method.When none of them returns a requester, submission throws
MissingRequesterException. No requester could withdraw an unattributed
request, and its audit trail would name no one, so the package refuses to
create one.
class PurchaseOrder extends Model
{
use Approvable;
public function approvalRequester(): ?Model
{
return $this->buyer;
}
}Returning null falls through to the authenticated user.
Submission runs every check before it opens the transaction, so a misconfigured stage three cannot leave a half-built request behind. The checks run in this order:
| Check | Failure |
|---|---|
| The record is persisted | InvalidDraftException::unsavedApprovable() |
| No unresolved request exists for the record | ApprovalInProgressException |
| The draft is not an empty array | InvalidDraftException::emptyDraft() |
| Every drafted key is allowed | InvalidDraftException::undraftableAttributes() |
| The workflow is active | InvalidWorkflowException::inactive() |
| The workflow has stages | InvalidWorkflowException::hasNoStages() |
| Each stage's rejection policy is known | InvalidWorkflowException::unknownRejectionPolicy() |
Each condition class is a StageCondition | InvalidWorkflowException::notACondition() |
Each resolver class is an ApproverResolver | InvalidWorkflowException::notAResolver() |
| Resolvers return persisted models | InvalidWorkflowException::resolverReturnedUnsavedModel() |
| An applicable stage resolved some approvers | InvalidWorkflowException::stageHasNoApprovers() |
| Each threshold is reachable | InvalidWorkflowException::approvalRuleUnreachable() |
A workflow that cannot produce a decision fails at submit time rather than stalling at stage three weeks later.
Inside the transaction, submission locks the record's row and writes these rows:
pending, with the draft and the original values the draft
was made against.submitted audit action, attributed to the requester.stage_skipped action for each stage a condition turned off.Submission then activates the first stage whose condition passed. Activation
sets that stage's deadline. ApprovalSubmitted fires with that stage.
A requester withdraws their own pending request:
Approval::withdraw($request, $requester, 'Raised in error');Only the request's requester may withdraw it. Check eligibility before you render a withdraw control:
$request->canBeWithdrawnBy($user); // bool
Approval::withdrawalEligibility($request, $user); // with a reasonWithdrawal refuses with WithdrawalIneligibility::RequestClosed or
NotRequester.
Administrative cancellation is a separate operation that your application authorizes. See eligibility and authorization.
The requester or an approver may attach a file to an open request as evidence:
use ByRcsc\LaravelApproval\Data\AttachmentEvidence;
Approval::attach(new AttachmentEvidence(
request: $request,
disk: 's3',
path: 'quotes/PO-1001.pdf',
checksum: hash_file('sha256', $localPath),
name: 'Supplier quote.pdf',
mimeType: 'application/pdf',
size: $bytes,
uploader: $requester,
));The package records a reference and a digest, never bytes. Storing and serving the file stays with your application. See audit trail and evidence.