Partial (Per-Item) Approval
By default a pending Modification is decided as a whole: 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 extra rows — this package favors audit history over searchability, so it never creates a row it doesn’t need.
Opting in
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():
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, decided globally as before. Keys covered by a group each get one ModificationItem row per group, not per field — a group can bundle several fields into a single JSON original/proposed pair, keeping row counts low.
Deciding items 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'
Each decision records author, reason, and timestamp on the Approval/Disapproval row, scoped to that item via modification_item_id. Duplicate votes are collapsed and a user can change their vote, same as the global flow.
Interoperability with the global flow
approve($modification)/disapprove($modification) stay interoperable with item-level decisions:
- If the
Modificationhas no items → decides the whole thing (legacy behavior, unchanged). - If the
Modificationhas items → resolves every still-pending item atomically, in one transaction, without the caller needing to knowModificationItemexists.
When an item resolves
- Reaching
approvers_requiredon an item applies only that item’s fields to the model and marks itapplied. - Reaching
disapprovers_requiredon an item marks itrejectedwithout touching the model. - After every decision, the parent’s
statusis recalculated from its items:pendingwhile any item is still pending,approvedonce all are applied/approved,rejectedonce all are rejected,partially_resolvedfor a mix.
Next: Recording Manual Events.