byrcsc/laravel-approval · 1.x
Submitting for approval.
Submit a persisted record with or without a draft, control workflow and requester resolution, and understand what submission validates before writing anything.
Submission is explicit. The package does not intercept saves to decide that something needs approval; you say so.
$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.
Signature
Approval::submit(
Model $approvable,
ApprovalWorkflow|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, or null to resolve it |
$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 |
Record-level versus draft submissions
Passing null for $newAttributes creates a record-level request: the
already-saved record is what needs approval, and nothing is applied when the
request completes.
Passing an array creates a draft request: those values are held on the request and written to the record only after the final approval. See attribute drafts.
An empty array is neither, and raises InvalidDraftException::emptyDraft().
Null and [] are deliberately different: one means "approve the record", the
other is almost always a bug.
Workflow resolution
The $workflow argument wins outright: a workflow named at the call site is
used as named, active or not.
With nothing named, candidates are gathered from three places, most specific
first — the model's approvalWorkflow() method, the approval.workflows config
map, and every workflow whose approvable_type column names the model:
// config/approval.php
'workflows' => [
App\Models\PurchaseOrder::class => 'purchase-order',
],Whatever is gathered is then filtered. An inactive workflow is out, and so is
one whose condition rules it out for this submission. What survives is ordered
by where it came from and then by priority, and the first is used. When nothing
survives, 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.
Requester resolution
The same shape, three sources:
- the
$requesterargument; - the model's
approvalRequester()method; - the authenticated user.
When none answers, submission throws MissingRequesterException. An
unattributed request cannot be withdrawn by its requester or meaningfully
audited, 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.
What submission validates
Everything that can fail is settled before the transaction opens, so a misconfigured stage three cannot leave a half-built request behind. In 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.
What submission writes
Inside the transaction, with a row lock on the record:
- the request, in
pending, with the draft and the original values it was drafted against; - one request stage per workflow stage, carrying the full snapshot and the evaluated condition result;
- one assignment per resolved approver;
- a
submittedaudit action, attributed to the requester; - a
stage_skippedaction for each stage a condition turned off.
The first stage whose condition passed is then activated, which is what sets
its deadline. ApprovalSubmitted fires with that stage.
Withdrawing
A requester withdraws their own pending request:
Approval::withdraw($request, $requester, 'Raised in error');Only the request's requester may withdraw it. Ask first when rendering a control:
$request->canBeWithdrawnBy($user); // bool
Approval::withdrawalEligibility($request, $user); // with a reasonThe two refusal reasons are WithdrawalIneligibility::RequestClosed and
NotRequester.
Administrative cancellation is a separate, host-authorized operation — see eligibility and authorization.
Attaching evidence
Either party may put a file in evidence against an open request:
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.