›
byrcsc/laravel-approval · 1.x
Hold proposed attributes on a request and apply them after final approval.
A draft holds proposed attribute values on the request until approval. The package writes them 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.
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. The package throws
instead of filtering the draft. A draft that silently dropped half its keys
would get approval for changes the package was never going to apply.
$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.
An approval can take days, and someone may edit the record while it runs. Applying an old draft over those edits would undo them. Before the engine writes the draft, it compares each drafted attribute against the value snapshotted at submission.
The engine compares cast values, never 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 kind of
problem. The engine reads both sides 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 engine merges the snapshot over the record's own attributes before casting. That merge handles a sibling cast as long as the sibling has not also changed.
If nothing drifted, the engine applies the draft, DraftApplied and
ApprovalApproved fire, and the request reaches approved.
If a drafted attribute did move, the engine writes nothing. The request becomes
conflicted, DraftConflicted fires naming the attributes that drifted, and
the request waits for a person.
A conflict is a request state rather than an exception. The final approver's decision was valid, so the engine records it. The approver does not get an exception for a change they did not make.
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.
A request also conflicts when someone deletes its record. In that case,
DraftConflicted carries a null $approvable.
use ByRcsc\LaravelApproval\Enums\ConflictResolution;
Approval::resolve($request, ConflictResolution::Reapply, $administrator);
Approval::resolve($request, ConflictResolution::Discard, $administrator, 'Superseded');| Resolution | Effect |
|---|---|
Reapply | Writes the draft over the record's current values |
Discard | Cancels the request and leaves the record unchanged |
The package records a reapply as a forced apply, attributed to the operator, and keeps the attributes that had drifted in the history. It records a discard as an attributable administrative cancellation, so a discard 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. The conflict asks a different question, whether the change still applies to the record, and the resolution answers that question.
A conflicted request can also be revised and resubmitted, which supersedes it rather than resolving it.
Explicit mode is the default. In explicit mode, 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, so guard mode does not see them. The stale check before draft application catches drift from those paths.
To correct a record while a draft is pending, run the change inside
withoutApprovalGuard():
$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 package appends the bypass to the open
request's audit trail as a guard_bypassed action. The bypass is re-entrant
and scoped to the record. When a nested bypass closes, the bypass around it
stays active.
With no unresolved request, the callback runs and nothing is recorded.
RevisedDraft names the three choices explicitly when resubmitting, because
null cannot carry all of them. See
returns and resubmission.