Changelog
Mirrors
CHANGELOG.mdat the repository root.
All notable changes to this project are documented here. This project follows Semantic Versioning.
0.6.1 - 2026-11-03
- Fixed: since 0.6.0,
forceModification()on a gated save with nothing dirty saved directly and left no pending row, so a change kept outside the columns, such as a photo, went through with no approval. It now holds a pendingModificationwith an empty diff. Approving or rejecting it closes it and writes no column. - Fixed:
forceModification()stayed on for every later save of the instance. It now covers the next save only. - Documented: a 0.6.0 change missing from its notes. A manual
ApprovablewhoseapplyModificationChanges()doesn’t close theModificationhas its vote and its database effects undone, andapprove()returnsfalse. - Documented: how repeated requests are treated, and why requests with an empty diff need a check of your own.
- Added: a class reference generated with Doctum, at
docs/api, and docblocks on the public API. Build it withbin/bench api-docs.
0.6.0 - 2026-11-03
- Fixed: one approver could fill several approval slots by voting with different reasons. Each approver now holds one vote per decision, and the first reason is kept.
- Fixed: a change could be voted on after being decided. Reapproving overwrote newer data, disapproving marked an applied change
rejected, and approving a direct audit row rolled the model back. All of them now returnfalseand change nothing. - Changed: a rejection is final. Approving a
rejectedchange or item no longer applies it; send the change again. A vote can still switch sides while the change is undecided. - Fixed: approving a modification with items silently lost the fields outside every group. They now go into an extra item keyed
_ungrouped. - Fixed:
encryptedattributes were stored in plain text inmod. - Fixed: a direct save recorded an
appliedaudit row even if a listener or the database then refused it. The row is now written after the save. - Changed: attributes with an accessor or mutator, an enum,
encrypted*,timestampor custom class cast keep their stored form inori/mod, and approving writes it back as is. Before, a mutator ran twice, an accessor’s output was written to the column, thetimestampcast wrote an integer, and value objects threw. - Fixed:
AsFluentattributes gained awherekey and couldn’t lose keys when approved. They’re now stored whole. - Fixed: approving reported
approvedwhen a listener vetoed the save. The change stays pending and the call returnsfalse. - Fixed: a failure while applying a change left the vote behind. The vote and its effect now share a transaction, with the row locked.
- Fixed: full-save modifications overwrote columns changed since the request, lost changed columns that hadn’t been selected, and threw when nothing was dirty.
- Fixed: a gated save with nothing to decide (no changes,
touch(), onlynoTrackModificationcolumns) left an empty pending row and blocked the save. It now saves directly, and untracked columns changed alongside tracked ones are held in the diff instead of lost. - Fixed: saving threw for a model extending an abstract base using the trait, an accessor reading another attribute,
AsStringable, non-backed enums, and binary values. - Fixed: approving threw when the model no longer existed, or when a
deleteWhenApprovedrow was approved again. It now returnsfalse, and the pending change can still be rejected. - Fixed: morph maps were ignored by votes,
modifier_typeandcreations(). - Fixed: creating a model with an explicit primary key went through the gate and the audit trail as if it were an update.
- Fixed:
requiresApproval()ignored forced updates, so approving could open a new pending row;setForcedApprovalUpdate()stayed active past one save;withModificationMeta()repeated on later saves and lost scalar values; and auditing stayed off on the instance afterforceApprovalUpdate(). - Fixed:
forceApprovalUpdate()left items pending,updateWhenApproved = falseleft the row pending forever, andapproversRemaining/disapproversRemainingcounted item votes. - Fixed: a held change stayed dirty on the instance, so its next save carried it along. A later gated save could apply it after it had been rejected, and an ungated one wrote it with no approval.
- Changed: after a gated save the instance holds the stored values again. Read the proposed ones from
getLastModification(). - Fixed: array changes smaller than the differ’s float tolerance, or only reordering keys, produced an empty diff: saved with no audit row when direct, and approved into nothing when gated. Whole values are now compared exactly.
- Changed:
oriuses the same format asmod. Adateno longer reads2026-01-01 00:00:00on one side and2026-01-01on the other, aboolean1andtrue, or a clearedAsCollectionthe string"null". - Fixed: non-backed enums inside
AsEnumCollection/AsEnumArrayObjectthrew on every save. - Changed: the
objectcast keeps its stored JSON inori/mod, so an empty object is no longer written back as[]. - Changed: rows waiting for approval add
rawwhen what a save would store isn’t exactlymod, and approving writes it. Approving now stores exactly what saving directly would have. Before, it roundeddecimalvalues, truncatedintegercasts, lost a change of time on adate, ran mutators again on array casts, and threw on accessors returning arrays and on custom casts validating their input. - Fixed: columns named like Eloquent’s own properties (
hidden,table,visible,connection,original…) were stored wrong and never written. - Fixed: user data shaped like the binary marker (
__base64) was decoded on approval, replacing the column. - Fixed: setting an unselected column to
nullproduced an empty diff and was never written. - Fixed:
Modification/ModificationItemsubclasses with another class name crashed on approve (wrong foreign key). The README promises those can be swapped in. - Fixed: a draft’s request was lost when a
createdlistener saved the model, leaving a hidden row nobody could decide. - Fixed: a retry after a failed draft insert kept the row hidden even when it no longer needed approval.
- Fixed: approving a modification with items wasn’t atomic when a listener vetoed one: it returned
falsewith other items already written. - Fixed: rejecting a draft whose delete was vetoed reported
rejectedwith the row still there. It now stays pending and returnsfalse. - Fixed:
forceApprovalUpdate()applied rejected changes and rejected items, and rolled the model back from audit rows. It now only acts on what’s pending. - Fixed: a gated
restore()could never be approved, because a trashed model counted as gone. - Fixed: a soft-deleting
Modificationsubclass withdeleteWhenApprovedreported failure and could be applied again. - Fixed: full saves with item groups created items for untouched columns, leaving the row pending forever.
- Fixed:
recordModification()threw on binary values, and a manual event afterdelete()counted as a creation. - Fixed: the meta of a repeated request reached the next one, the same deferred creation with its attributes in another order made two requests, and the
add_flow_and_modification_itemsmigration couldn’t roll back on SQLite. - Fixed: removing a key from a schemaless attribute is now recorded, and approved by writing the whole value. It was a known limitation since 0.5.0.
- Fixed: a
createdlistener saving a gated model wiped the instance, down to its id. A listener saving during a save or an approval no longer requests over again what was just written. - Fixed: a model deleted and saved again under a creation gate requested nothing, and approving inserted a blank row.
- Fixed: a raw SQL expression held for approval was written as
[], and a model loaded without its key inserted a second row on approval. Both now throw when held. - Fixed: saving threw on an enum value since removed from its enum, on an accessor needing an unselected column, and on an enum assigned to an uncasted column.
- Fixed: a model on another connection looked for its modifications there while they were saved on the default one, and the reverse from
Modification. - Changed: a vetoed write undoes the whole decision, its vote included, on every path.
forceApprovalUpdate()is all or nothing too, and restores the instance’s status if it fails. - Changed: the audit row of an update is written right after the row, so
updatedlisteners can read it withgetLastModification(). - Fixed: in a schemaless attribute, removing a nested key or changing only the key order left an empty diff, and a change smaller than a float tolerance was missed. Sub-keys are now compared exactly.
- Removed: the
rogervila/array-diff-multidimensionaldependency, no longer used. - Fixed: the same change requested twice at the same instant was held twice. Requests for one row now wait for each other.
- Fixed:
savinglisteners registered after this package’s, which include those inbooted()and observers, never ran, so they couldn’t change values or veto a save. They now run on any save the gate lets through, and on approval. - Fixed:
occurred_atignored the app’s clock, soCarbon::setTestNow()andtravelTo()didn’t reach it. - Fixed: on Postgres, saving a model with a changed
byteacolumn threw, since it reads back as a stream, and approving bytes failed because they were written as text. - Added: creation approval. A model implementing
Contracts\GatesCreationcan hold a creation until it’s approved, either with no row (deferred, the default) or with a hidden one (draft, which needs a boolean column). Set the mode inapprovals.creation.modeor per model withapprovalCreationMode(). In both modescreated_atis the request time, andforceApprovalCreate()resolves a request with no vote. Models that don’t opt in save directly as before. - Added:
approversRequired()/disapproversRequired()to override on the model, since redeclaring the trait’s properties with another value is a PHP fatal error.
0.5.0 - 2026-11-02
- Fixed: approving a change to a date attribute (
date,datetime,immutable_datetime) never wrote it, including when clearing it tonull. Carbon has its ownset(), so the date was treated as a schemaless attribute: PHP warned on theforeachover the proposed string, which a Laravel app turns into anErrorExceptionwith the vote saved and the change still pending. Attributes exposingset()are now merged key by key only when the proposed value is an array. - Fixed: approving a change to an
array,collection,AsCollectionorAsArrayObjectattribute replaced the column with only the part that changed, losing the rest. Removing elements or keys recorded nothing: a gated change left a pending row with an empty diff that changed nothing when approved, and a direct one saved without an audit row.AsArrayObjectattributes threw while building the diff. - Changed: for those attributes,
ori/modnow hold the whole value instead of the changed sub-keys. Code reading sub-keys fromori/modmust compare the two sides itself. Attributes exposingset()(e.g. schemaless attributes) keep their partial diff. - Upgrading:
Modificationrows already pending keep their old partial diff, and approving one still replaces the column with that fragment. Resolve them before upgrading.
0.4.2 - 2026-10-30
- Fixed: a
datetime/timestampattribute lost its time in the diff (it was always formattedY-m-d). Onlydatecasts keepY-m-dnow; any other date keeps the model’s storage format. - Fixed: setting an array/
ArrayAccessattribute (e.g. a JSON column cast toarray) tonullor a scalar threw$array1 must be an array!while building the diff. It’s now recorded as a whole replacement (ori= previous value,mod= new value).
0.4.1 - 2026-10-30
- Fixed: saving the same gated change while an identical one is still pending no longer refills the pending
Modificationin memory with the new requester/reason/date (without saving it). The original request is kept untouched and returned bygetLastModification().
0.4.0 - 2026-08-25
- Added support for Laravel 13 (
illuminate/*^13.0). Laravel 11 and 12 remain supported. - Added idempotent lookup indexes for approval owners and pending-modification queries, including legacy installations whose pre-existing tables skipped the package’s create migration.
0.3.1 - 2026-07-28
- Reworked the
create_approvals_tablesmigration so it is idempotent on installations whose tables already exist.
0.3.0 - 2026-07-28
Modificationgainsflow(direct/approval),status(applied/pending/approved/rejected/partially_resolved),event_type,operation_id,context,occurred_at.active/is_modificationare now derived fromflow/statusand kept for backward compatibility.- Added
ModificationItem(modification_itemstable): an opt-in, per-group decidable unit inside aModification. A model opts in by overridingapprovableItemGroups(); groups store their diff as JSONoriginal/proposed(like the existing diff format), not one row per field, to keep this an audit-friendly, low-row-count design. - Added
modification_item_id(nullable) toapprovals/disapprovals: a vote scoped to an item decides only that item; a vote without it is a global decision, unchanged from before. - Added
$user->approveItem()/$user->disapproveItem().approve()/disapprove()stay interoperable: they decide the wholeModificationwhen it has no items (legacy behavior, unchanged), or resolve all pending items atomically when it has them. - Added
$model->recordModification(): an explicit history API for events outside ofsaving(creation, deletion, attachments, signatures, external integrations). - Added
withOperationId()to correlate multipleModificationrows produced by the same logical operation. - Deprecated
deleteWhenApproved/deleteWhenDisapproved(still functional). - New migration
2024_01_02_000000_add_flow_and_modification_items.php; backfillsflow/statuson existingmodificationsrows. See Upgrading.
0.2.0 - 2026-07-28
- Documented the
ori/moddiff structure stored inModification::$modifications. - Made the
ori/moddiff keys configurable viaconfig('approvals.diff_keys.*').
0.1.0 - 2026-07-28
- Initial release:
RequiresApprovalWhenChanges/ApprovesChangesconcerns,Modification/Approval/Disapprovalmodels, config-based model resolution, optionalHasModificationMediatrait, and a Capsule-based PHPUnit test suite.