Browse documentationOpen

byrcsc/laravel-approval · 1.x

Audit trail and evidence.

The append-only, hash-chained action history, what attachment evidence records, and exactly what a clean verification does and does not cover.

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.

Action types

use ByRcsc\LaravelApproval\Enums\ActionType;
ActionWritten when
submittedA request is created
approvedAn approval is recorded
rejectedA rejection is recorded
returnedA request is returned for revision
withdrawnA requester withdraws
cancelledAn administrator cancels
system_closedThe engine closes a request
supersededA conflicted request is replaced by a revision
resubmittedA replacement request is created
delegatedAn approver hands an assignment on
reassignedAn administrator rewrites an assignment
resyncedA live request's assignments are rebuilt
escalatedAn overdue stage's policy runs
remindedAn overdue reminder is emitted
stage_skippedA condition turned a stage off at submission
appliedA draft is written onto the record
conflictedA draft could not be applied
guard_bypassedGuard mode was bypassed through the escape hatch
attachment_addedEvidence is attached
commentedA comment is recorded on its own

Some have no human actor: escalated, applied, and conflicted are the system's. That distinction matters — "the deadline passed and the stage was approved" is a different fact from "an approver approved it".

What an action stores

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;
}

actor_label is captured when the line is written and never refreshed, so the trail still names the actor after the account is renamed or deleted. The morph key alone stops meaning anything the day the row it points at disappears, and "who approved this?" is exactly the question that must still be answerable then.

There is no updated_at. The ApprovalAction model refuses updates and deletes.

The hash chain

Each action's SHA-256 hash covers its own content and the hash of the action before it, per request. The request row carries the trusted chain head, which is locked and advanced atomically on every append — so concurrent writers cannot fork the chain, and removing the final action is as visible as removing one from the middle.

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

Append-only is enforced by the ApprovalAction and ApprovalAttachment models. A direct query-builder or SQL write bypasses that enforcement, which is why the schema also uses restrictOnDelete from actions and attachments to requests: erasing history has to be a deliberate act against those tables.

A deleted stage nulls the action's stage_id rather than cascading, so losing a stage does not erase what people did on it — and the nulled reference breaks that line's hash, making the loss visible.

Attachment evidence

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 checksum is written into the audit action that records the attachment, which puts the file's content — not just its name — under the hash chain.

action is optional and links the file to a specific decision: a signature image on an approval, say. Omit it for a document belonging to the request as a whole. An action from a different request is rejected.

The constructor validates its own arguments:

ProblemException
Blank disk or pathAttachmentException::blankLocation()
Checksum is not 64 hex charactersAttachmentException::invalidChecksum()
Negative sizeAttachmentException::invalidSize()
Action belongs to another requestAttachmentException::actionNotOnRequest()

Verifying

php artisan approval:verify
php artisan approval:verify 01JBK…

Exits non-zero and lists every finding. Approval::verifyTrail($only) is the programmatic form, for hosts wiring verification into their own monitoring:

$findings = Approval::verifyTrail();

// [['request' => '01JBK…', 'subject' => 'action #44', 'problem' => '…'], …]

Four kinds of finding, in the order they are checked:

  1. an action whose previous-hash does not point at the action before it — removed, reordered, or inserted history;
  2. an action whose content no longer matches its own hash — edited in place;
  3. a final action that does not match the request's recorded chain head;
  4. an attachment inconsistent with the history: a row no action recorded, a recorded file whose row is gone, or a row whose location or checksum differs from the metadata recorded at attach time.

Findings are reported, never repaired. Deciding what a broken chain means is the operator's decision.

What a clean result covers

  • action content, and the chain linkage between actions;
  • the request's recorded chain head;
  • attachment rows; and
  • the attachment metadata recorded at attach time, including the checksum the application supplied.

What it does not cover

  • Request state, draft attributes, workflow stage snapshots, and assignment rows. None of these are chained.
  • The stored files themselves. Their bytes are never read and their checksums are never recomputed. A checksum row that matches its recorded action is treated as valid. Reading storage disks is out of scope for the current verifier.

The package provides no cryptographic signatures, HMAC protection, external hash anchoring, or write-once storage. An organization needing those should layer them over the trail rather than assume the package supplies them.

Running it on a schedule

Schedule::command('approval:verify')->dailyAt('03:00');

The command walks every request in chunks, so a large trail is safe to verify in one pass. Route its failure to whatever watches your scheduler's exit codes.