Skip to content

Approval Workflow ​

The flow Bouncer takes a save through, the database row that captures it, and the helpers on the entity that drive the UI.

How a save becomes a draft ​

user calls $table->save($entity, ['bouncerUserId' => $userId])
        │
        ▼
BouncerBehavior::beforeSave  (or beforeDelete for deletes)
        │
        ├── bypassBouncer option set? ─→ pass through, normal save
        ├── exemptUsers / bypassCallback matches? ─→ pass through
        ├── action not in requireApproval? ─→ pass through
        │
        ▼
serialize entity to JSON, write a bouncer_record row (status='pending')
        │
        ▼
short-circuit the original save (return false from beforeSave)
        │
        ▼
behavior remembers wasBounced() = true for the controller to read

On approval the flow runs in reverse: the bouncer_record's data payload is patched back onto a fresh entity from the source table, validated again (because draft validation isn't a substitute for save-time rules), and saved with bouncer bypassed for that specific call so it doesn't re-loop.

Re-edit deduplication ​

When the same user edits the same record again before the previous proposal was reviewed, Bouncer doesn't stack drafts:

  1. The controller calls loadDraft($primaryKey, $userId) and gets the existing pending row.
  2. The form is rendered with the draft's data overlaid on the published record.
  3. On submit, BouncerBehavior::beforeSave finds the existing pending draft for (source, primary_key, user_id) and updates it rather than inserting a new one.

Different users editing the same record do get separate drafts — otherwise concurrent contributors would silently overwrite each other. With autoSupersede => true (default), submitting a new proposal for the same record marks the user's previous pending drafts as superseded so the queue stays focused on the latest version.

Database schema ​

The bouncer_records table stores one row per proposal:

ColumnTypePurpose
idinteger / uuidprimary key
sourcestringmodel name (Articles, Community.Stories for plugins)
primary_keyinteger / uuid / nullsource-record id, NULL for new-record proposals
user_idinteger / uuidwho proposed
user_displaystring / nulloptional display name for the proposer
reviewer_idinteger / uuid / nullwho approved or rejected
reviewer_displaystring / nulloptional display name for the reviewer
statusenumpending / approved / rejected / superseded
dataJSONproposed changes (full entity payload for new records, dirty fields for edits)
original_dataJSON / nullsnapshot of the source record at draft time — drives diffs and 3-way merge
original_modifieddatetime / nullsource record's modified timestamp at draft time — drives staleness detection
notestring / nullthe proposer's note explaining what they changed and why
reasontext / nullthe reviewer's note when approving or rejecting
created / modified / revieweddatetimethe usual lifecycle timestamps

primary_key is null for proposals that would create a new row. original_data is null for the same reason.

IMPORTANT

note and reason are two different fields with two different authors. The bundled admin viewer renders them as "User Note:" (the proposer's note) and "Reviewer Reason:" (the reviewer's reason). Don't conflate them — see Capturing a proposer note for how to populate note from your forms.

Status transitions ​

                ┌─────────┐
                │ pending │ ← every new proposal starts here
                └────┬────┘
            ┌───────┼───────┐
            ▼       ▼       ▼
    ┌────────────┐ ┌──────────┐ ┌────────────┐
    │  approved  │ │ rejected │ │ superseded │
    └────────────┘ └──────────┘ └────────────┘

Statuses are terminal — once a row leaves pending, the queue stops showing it (unless the admin filters for that status).

Entity helpers on BouncerRecord ​

The entity exposes predicates and accessors that the admin UI uses internally and that are useful in your own templates and custom flows.

Status predicates ​

MethodReturns
isPending()true when status === 'pending'
isApproved()true when status === 'approved'
isRejected()true when status === 'rejected'

Proposal-type predicates ​

MethodReturns
isNewRecordProposal()true when the proposal would create a row (primary_key is null)
isEditProposal()true when the proposal would modify an existing row
isDeleteProposal()true when the proposal would delete an existing row

These drive BouncerHelper::recordTypeBadge() — use them when you need the same branching in your own templates.

Data accessors ​

MethodReturns
getData()proposed payload — the merged data if setMergedData() was called, otherwise the original proposal
getOriginalData()snapshot of the source record at draft time
hasMergedData()true if a custom merge result has been set
getMergedData()the explicitly-set merged payload (empty array if none)

Staleness and merging ​

MethodReturns
hasOriginalModified()true if the row has an original_modified timestamp
canDetectStaleness()true if staleness can be evaluated (proposal is an edit + has original_modified)
isStale($currentSourceEntity)true if the source has moved since the draft was made
buildMergeResult($currentSourceEntity, $skipFields = ['id', 'created', 'modified', '_delete'])full 3-way merge result, or null if not stale
setMergedData(array $mergedData)override the apply payload (for UI-driven conflict resolution)
php
// Standard pattern: only render the merge UI when there's something to merge
if ($bouncerRecord->isEditProposal() && $bouncerRecord->canDetectStaleness()) {
    $current = $articles->get($bouncerRecord->primary_key);

    if ($bouncerRecord->isStale($current)) {
        $result = $bouncerRecord->buildMergeResult($current);
        if ($result['hasConflicts']) {
            // surface conflict UI; resolve into $mergedData; then:
            $bouncerRecord->setMergedData($mergedData);
        }
    }
}

UUID support ​

Bundled migrations use integer columns. UUID-keyed apps need to copy and adjust them — see Configuration → Polymorphic Column Type.

Released under the MIT License.