Browse documentationOpen

byrcsc/laravel-approval · 1.x

Attribute drafts.

Hold proposed attribute values until approval, restrict which attributes may be drafted, understand the cast-aware stale check, and resolve conflicts.

A draft is the package's hold-until-approved mechanism. Proposed attribute values travel with the request and are written to the record only after the last required approval.

$request = $order->submitForApproval('purchase-order', [
    'amount' => 10_000,
    'notes' => 'Revised after negotiation',
]);

$order->amount;              // still 4000
$order->pendingDraft()->new; // ['amount' => 10000, 'notes' => '…']

Drafts cover model attributes only. Relationship changes and uploaded file contents remain the application's responsibility.

Restricting what may be drafted

By default every attribute may be drafted. Return an explicit list on models with columns the approval workflow should not be able to change:

public function approvableAttributes(): ?array
{
    return ['amount', 'supplier_id', 'notes'];
}

A draft naming anything outside the list raises InvalidDraftException listing both the rejected keys and the allowed ones. It fails loudly rather than filtering: a draft that silently drops half its keys gets approved on the strength of changes that were never going to be applied.

Reading a draft

$draft = $order->pendingDraft();          // null when there is none

$draft->new;        // proposed values
$draft->original;   // raw column values captured at submission
$draft->keys();     // ['amount', 'notes']
$draft->changes();  // ['amount' => ['from' => 4000, 'to' => 10000], …]

changes() is the shape worth putting in a diff view or an audit log.

pendingDraft() returns null both when there is no unresolved request and when the unresolved request is record-level. Use pendingApprovalRequest() to tell them apart.

The stale check

An approval can take days, and the record may be edited while it runs. Applying an old draft over those edits would undo them, so before the draft is written the engine compares each drafted attribute against the value snapshotted at submission.

The comparison is made on cast values, never on raw columns. Encrypted attributes produce different ciphertext on every write; decimal:2 columns come back as strings only after casting; dates, enums, and JSON have the same shape of problem. Both sides are read through the same model class, so the same casts apply to each and only the values differ.

The documented limit is casts that read sibling attributes: the snapshot is merged over the record's own attributes before casting, which covers them as long as the sibling has not also moved.

If nothing drifted, the draft is applied, DraftApplied and ApprovalApproved fire, and the request reaches approved.

Conflicts

If a drafted attribute did move, nothing is written. The request becomes conflicted, DraftConflicted fires naming the attributes that drifted, and the request waits for a person.

That is a request state rather than an exception. The final approver's decision was valid, so it is recorded — the request does not blow up in their face for something they did not do.

A conflicted request receives no completion timestamp and sends no approved notification. It is still an unresolved request, so the record cannot be submitted again until it is settled.

The other way a request conflicts is the record being deleted underneath it, in which case DraftConflicted carries a null $approvable.

Resolving a conflict

use ByRcsc\LaravelApproval\Enums\ConflictResolution;

Approval::resolve($request, ConflictResolution::Reapply, $administrator);
Approval::resolve($request, ConflictResolution::Discard, $administrator, 'Superseded');
ResolutionEffect
ReapplyWrites the draft over the record's current values
DiscardCancels the request and leaves the record unchanged

A reapply is recorded as a forced apply, attributed to the operator, with the attributes that had drifted kept in the history. A discard is recorded as an attributable administrative cancellation, so it requires an actor.

There is deliberately no merge resolution. Choosing between two competing changes depends on application-specific meaning the package does not have.

Neither resolution requires re-approval. The stages already approved the change itself; what the conflict raised was whether it still applied to the record, which is the question this answers.

A conflicted request can also be revised and resubmitted, which supersedes it rather than resolving it.

Guard mode

Explicit mode is the default: the package intercepts nothing, and an ordinary save() to a drafted attribute succeeds while its request is pending.

Guard mode rejects those writes:

// config/approval.php
'models' => [
    App\Models\PurchaseOrder::class => ['mode' => 'guard'],
],

Or on the model:

public function approvalMode(): ApprovalMode
{
    return ApprovalMode::Guard;
}

With guard mode on, an Eloquent save() or update() touching an attribute held in a pending draft throws ApprovalGuardException naming the attributes.

Guard mode covers Eloquent model events only. Query-builder updates, upserts, quiet saves, and raw SQL do not dispatch model events and stay outside the boundary. The stale check before draft application is the backstop for those paths.

The audited escape hatch

Corrections still have to be possible while a draft is held:

$order->withoutApprovalGuard(
    fn ($order) => $order->update(['notes' => 'Corrected typo']),
    actor: $administrator,
    comment: 'Fixing a data-entry error',
);

Approval::withoutGuard($order, $callback, $actor, $comment) is the same operation from the facade. The bypass is appended to the open request's audit trail as a guard_bypassed action. It is re-entrant and scoped to the record, so a nested bypass closing does not disarm the one around it.

With no unresolved request, the callback simply runs and nothing is recorded.

Drafts through resubmission

RevisedDraft names the three choices explicitly when resubmitting, because null cannot carry all of them. See returns and resubmission.