Browse documentationOpen

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;
ArgumentNotes
$approvableMust already be persisted
$workflowA slug, a workflow model, or null to resolve it
$newAttributesA draft, or null to submit the record itself
$requesterNull to resolve it
$commentRecorded on the submitted audit action
$metadataRecorded 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:

  1. the $requester argument;
  2. the model's approvalRequester() method;
  3. 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:

CheckFailure
The record is persistedInvalidDraftException::unsavedApprovable()
No unresolved request exists for the recordApprovalInProgressException
The draft is not an empty arrayInvalidDraftException::emptyDraft()
Every drafted key is allowedInvalidDraftException::undraftableAttributes()
The workflow is activeInvalidWorkflowException::inactive()
The workflow has stagesInvalidWorkflowException::hasNoStages()
Each stage's rejection policy is knownInvalidWorkflowException::unknownRejectionPolicy()
Each condition class is a StageConditionInvalidWorkflowException::notACondition()
Each resolver class is an ApproverResolverInvalidWorkflowException::notAResolver()
Resolvers return persisted modelsInvalidWorkflowException::resolverReturnedUnsavedModel()
An applicable stage resolved some approversInvalidWorkflowException::stageHasNoApprovers()
Each threshold is reachableInvalidWorkflowException::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 submitted audit action, attributed to the requester;
  • a stage_skipped action 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 reason

The 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.