reynotech / laravel-approvals
Approval workflows for Eloquent models: capture pending changes, gate them behind N approvers/disapprovers, and apply the diff once approved.
Requires
- php: ^8.2
- illuminate/database: ^11.0 || ^12.0
- illuminate/support: ^11.0 || ^12.0
- rogervila/array-diff-multidimensional: ^2.1
Requires (Dev)
- illuminate/events: ^11.0 || ^12.0
- phpunit/phpunit: ^10.5
Suggests
- spatie/laravel-medialibrary: Allows attaching images to pending modifications via Concerns\HasModificationMedia.
- spatie/laravel-schemaless-attributes: Allows applying approved diffs onto schemaless attribute columns.
README
Approval workflows for Eloquent models: capture a pending change as a Modification, gate it behind N approvers/disapprovers, and apply the diff once approved.
Extracted from rex-server-next's RequiresApprovalWhenChanges / ApprovesChanges traits so the workflow can be unit tested in isolation and reused across apps.
📖 Full documentation: https://reynotech.github.io/laravel-approvals/
Requirements
- PHP 8.2+
illuminate/database/illuminate/support^11 || ^12
Installation
composer require reynotech/laravel-approvals php artisan vendor:publish --tag=approvals-migrations php artisan migrate
Optionally publish the config:
php artisan vendor:publish --tag=approvals-config
Usage
On the model whose changes should be gated:
use ReynoTECH\Approvals\Concerns\RequiresApprovalWhenChanges; use ReynoTECH\Approvals\Contracts\Approvable; class Client extends Model implements Approvable { use RequiresApprovalWhenChanges; protected int $approversRequired = 1; protected int $disapproversRequired = 1; // Decide when a change needs approval instead of saving directly. protected function requiresApprovalWhenTrait(array $dirty): bool { return array_key_exists('billing_address', $dirty); } }
On the model that casts approvals (e.g. User):
use ReynoTECH\Approvals\Concerns\ApprovesChanges; use ReynoTECH\Approvals\Contracts\Approver; class User extends Authenticatable implements Approver { use ApprovesChanges; }
$client->billing_address = $newAddress; $client->save(); // captured as a pending Modification instead of persisted $modification = $client->modifications()->activeOnly()->first(); $user->approve($modification, 'confirmed with client'); // once approvers_required is reached, the diff is applied and the row is updated
The modification diff
Modification::$modifications stores one entry per changed attribute, each as a pair of the original and modified value:
[
'status' => ['ori' => 'draft', 'mod' => 'active'],
]
ori— the attribute's value before the change (fromgetOriginal()/getRawOriginal()).mod— the attribute's value after the change, i.e. what gets written back once approved.
For array-like/schemaless attributes, ori/mod only contain the sub-keys that actually changed rather than the whole structure.
The ori/mod key names can be renamed via config, in case they collide with something in your app:
// config/approvals.php 'diff_keys' => [ 'original' => 'ori', 'modified' => 'mod', ],
Modification as the single source of history
Every save produces a Modification: flow is direct or approval, and status is one of applied, pending, approved, rejected or partially_resolved. active/is_modification are kept and still work exactly as before (they're derived from flow/status), but flow/status are the preferred contract going forward:
| flow | status | active | is_modification |
|---|---|---|---|
direct |
applied |
false | false |
approval |
pending |
true | true |
approval |
approved/rejected/partially_resolved |
false | true |
Per-item (partial) approval — opt-in
By default a pending Modification is decided as a whole, exactly like before: approve($modification)/disapprove($modification) apply or discard the entire diff, and no ModificationItem rows are created. This keeps the common case (a single field, or "all or nothing") free of any extra rows — this package favors audit history over searchability, so it never creates a row it doesn't need.
When an operation can touch independently-decidable groups of fields (e.g. rules can be approved while notes is rejected in the same save), override approvableItemGroups() to opt in:
class Report extends Model implements Approvable { use RequiresApprovalWhenChanges; protected function approvableItemGroups(array $dirtyKeys): array { return [ 'rules' => ['rules'], 'notes' => ['notes'], ]; } }
Any dirty key not covered by a group stays bundled into the parent Modification's own diff. Groups that are covered get one ModificationItem row each (not one row per field — a group can bundle several fields into a single JSON original/proposed pair, keeping row counts low), decided independently:
$modification = $report->modifications()->activeOnly()->first(); $rulesItem = $modification->items()->where('key', 'rules')->first(); $notesItem = $modification->items()->where('key', 'notes')->first(); $user->approveItem($rulesItem, 'rules look right'); // only `rules` is written back $user->disapproveItem($notesItem, 'notes are wrong'); // `notes` is left untouched $modification->fresh()->status; // 'partially_resolved'
approve($modification)/disapprove($modification) stay interoperable with this: called on a Modification that has items, they resolve every still-pending item atomically in one transaction, instead of requiring the caller to know about ModificationItem at all.
Recording history outside of save()
For events that don't go through Eloquent's saving hook — creation, deletion, attachments, signatures, calls to external integrations — use recordModification(). It always records a direct/applied entry (it documents something that already happened; it doesn't gate anything) and runs inside whatever transaction the caller is already in:
$model->recordModification( eventType: 'attachment_added', items: [ [ 'key' => 'attachments.contract', 'original' => null, 'proposed' => ['id' => 123, 'name' => 'contract.pdf'], ], ], context: ['label' => 'Contrato'], operationId: $operationId, );
operation_id
Pass withOperationId($id) before saving (or to recordModification()) to tag every Modification produced by one logical operation with the same operation_id, so they can be correlated later even if some were applied directly and others went through approval.
Extension points
modifier()— override to change how the "who made this change" resolver works (defaults toauth()->user()).authorizedToApprove()/authorizedToDisapprove()— override on the approver model to add role/policy checks.config('approvals.models.*')— swap in your ownModification/ModificationItem/Approval/Disapprovalsubclasses.config('approvals.diff_keys.*')— rename theori/modkeys used in the stored diff (see above).Concerns\HasModificationMedia— optional trait to attach images to a pendingModificationviaspatie/laravel-medialibrary(not a hard dependency of this package).- Diff application (
applyModificationChanges) writes back to any attribute whose current value is an object exposing aset($key, $value)method (e.g.spatie/laravel-schemaless-attributes) instead of overwriting it outright.
Known limitations
- Migration uses
nullableMorphs()/morphs(), which assume integer/bigint primary keys onmodifiable/modifier/approver/disapprover. UUID/ULID-keyed models are not supported out of the box. - Only updates go through the approval gate; model creation always saves directly (matching the behavior this package was extracted from).
Deprecations
deleteWhenApproved/deleteWhenDisapprovedare deprecated: prefer letting theModification/ModificationItemrow stay and simply reading itsstatus. They still work exactly as before.
Upgrading from < 0.3.0
Publish and run the new migration:
php artisan vendor:publish --tag=approvals-migrations php artisan migrate
It adds flow, status, event_type, operation_id, context, occurred_at to modifications, creates modification_items, and adds a nullable modification_item_id to approvals/disapprovals. Existing rows are backfilled automatically (flow/status derived from active/is_modification, and from vote counts for already-resolved rows). No consumer code changes are required: active, is_modification, approve(), disapprove() and the legacy diff format all keep working unchanged, and modification_items stays empty unless a model opts in via approvableItemGroups().
Testing
composer test
Credits
- Inspired in part by cloudcake/laravel-approval.