›
byrcsc/laravel-approval · 1.x
Verify approval actions and attachment evidence through an append-only hash chain.
Every decision and administrative change leaves an action behind. The history is application-enforced append-only and tamper-evident within a stated trust boundary. It is not immutable, tamper-proof, or externally anchored.
use ByRcsc\LaravelApproval\Enums\ActionType;| Action | Written when |
|---|---|
submitted | A request is created |
approved | An approval is recorded |
rejected | A rejection is recorded |
returned | A request is returned for revision |
withdrawn | A requester withdraws |
cancelled | An administrator cancels |
system_closed | The engine closes a request |
superseded | A conflicted request is replaced by a revision |
resubmitted | A replacement request is created |
delegated | An approver hands an assignment on |
delegation_ended | A standing delegation hands an assignment back |
reassigned | An administrator rewrites an assignment |
resynced | A live request's assignments are rebuilt |
escalated | An overdue stage's policy runs |
reminded | An overdue reminder is emitted |
stage_skipped | A condition turned a stage off at submission |
applied | A draft is written onto the record |
conflicted | A draft could not be applied |
guard_bypassed | Guard mode was bypassed through the escape hatch |
attachment_added | Evidence is attached |
commented | A comment is recorded on its own |
Some actions have no human actor. The system writes escalated,
delegation_ended, applied, and conflicted. The distinction matters. "The
deadline passed and the stage was approved" is a different fact from "an
approver approved it".
foreach ($request->actions as $action) {
$action->action; // ActionType
$action->actor_label; // readable name, captured at write time
$action->comment;
$action->metadata;
$action->hash;
$action->previous_hash;
$action->created_at;
}The package captures actor_label when it writes the action and never
refreshes it. The trail still names the actor after the account is renamed or
deleted. The morph key alone identifies nobody once the row it points at is
deleted, and "who approved this?" must still have an answer then.
There is no updated_at. The ApprovalAction model refuses updates and
deletes.
Each action's SHA-256 hash covers its own content and the hash of the previous action on the same request. The request row stores the trusted chain head. The package locks the head and advances it atomically on every append, so concurrent writers cannot fork the chain. Because the request stores the head, removing the final action is as visible as removing one from the middle.
The chain makes an edit, deletion, reordering, or insertion detectable.
The hash is unkeyed and stored beside the data it covers, so anyone who can write to the database directly can recompute a consistent chain. The guarantee holds against accidental and application-level modification, not against a database administrator.
The ApprovalAction and ApprovalAttachment models enforce append-only
writes. A direct query-builder or SQL write bypasses the models. For that
reason, the schema also uses restrictOnDelete from actions and attachments to
requests, so erasing history requires a deliberate delete against those
tables.
Deleting a stage sets the action's stage_id to null instead of deleting the
action, so the actions taken on that stage survive. The null reference also
breaks that action's hash, so the verifier reports the loss.
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,
metadata: ['source' => 'supplier-portal'],
uploader: $requester,
action: $approvalAction,
));The package records a reference into a host-owned filesystem disk, never bytes. Storing and serving the file stays with your application.
The package writes the checksum into the audit action that records the attachment. The hash chain then covers both the file's content and its name.
action is optional and links the file to a specific decision, such as a
signature image on an approval. Omit it for a document that belongs to the whole
request. The constructor rejects an action from a different request.
The constructor validates its own arguments:
| Problem | Exception |
|---|---|
| Blank disk or path | AttachmentException::blankLocation() |
| Checksum is not 64 hex characters | AttachmentException::invalidChecksum() |
| Negative size | AttachmentException::invalidSize() |
| Action belongs to another request | AttachmentException::actionNotOnRequest() |
php artisan approval:verify
php artisan approval:verify 01JBK…The command lists every finding and exits non-zero when it finds any.
Approval::verifyTrail($only) runs the same check from code, for applications
that add verification to their own monitoring:
$findings = Approval::verifyTrail();
// [['request' => '01JBK…', 'subject' => 'action #44', 'problem' => '…'], …]The verifier checks for these findings, in this order:
The verifier reports findings and never repairs them. The operator decides what a broken chain means.
The package provides no cryptographic signatures, HMAC protection, external hash anchoring, or write-once storage. If you need those protections, add them on top of the trail.
Schedule::command('approval:verify')->dailyAt('03:00');The command reads requests in chunks, so you can verify a large trail in one pass. Send its failures to the tool that monitors your scheduler's exit codes.