Troubleshooting
Changes save directly instead of being bounced
You expected a draft but the save hit the source table.
Check, in order:
- Behavior attached —
$this->Articles->hasBehavior('Bouncer')returnstrueat save time. If you attach it conditionally (e.g. only for non-admins), make sure the user actually went through the conditional branch. - Action in
requireApproval— defaults to['add', 'edit', 'delete']. If you set['edit'], new records pass through. - User ID is reaching the behavior — pass via
bouncerUserIdin save options, or have auser_idfield on the entity. Without one, bouncer can't attribute the draft and falls back tonull. - User isn't in
exemptUsers— check the configured list and the user's id type (string vs int). bypassCallbackdoesn't return truthy unintentionally — easy to writereturn $identityand have it bypass for any logged-in user. Return strict booleans.bypassBouncersave option not set —save($entity, ['bypassBouncer' => true])bypasses for that one call.
Drafts not loading on re-edit
User edits the same record but doesn't see their pending draft — they end up overwriting their own proposal.
Fix: call loadDraft($primaryKey, $userId) in the controller before rendering the form, and overlay the draft data on the published entity (see the Getting Started edit example).
loadDraft returns null for any of: no pending draft exists; the draft belongs to a different user; the draft is in a non-pending state. The common cause is a user ID mismatch — verify the bouncerUserId you pass is the same one stored on the bouncer record.
Validation errors when creating a draft
The user submits valid-looking data but the draft creation fails with validation errors.
Three options:
- Fix the validation rules —
validateOnDraft => true(default) runs the table's normal validation. If the rules are too strict for draft proposals (e.g., "must reference an approved category" when categories are themselves bouncer-managed), loosen them. - Defer validation to approval —
validateOnDraft => falseaccepts any payload as a draft. Validation only runs when the admin approves. - Add a draft-specific validator — split your validation into
validationDefault()andvalidationDraft()and have the bouncer behavior pick the draft variant.
Approve button does nothing visible
The admin clicks approve and… nothing changes. No flash message, or a generic failure message.
Investigate:
- Logs first —
applyApprovedChanges()is transactional. If it threw, the exception is inlogs/error.logeven when the UI is silent. - Source table still exists — if the proposal is for a record that's since been deleted, the apply will fail. Check
bouncerRecord->primary_keyagainst the source. - Source table validation — even if
validateOnDraftis true, the source table's save-time rules run again on apply. A unique-key collision that didn't exist when the draft was made will now reject. - Database constraints — foreign key / unique violations bubble up as exceptions. The admin UI catches them and re-renders with an error; the
bouncer_recordstays inpending. - Stale proposal with conflicts — if 3-way merge surfaced unresolvable conflicts,
applyApprovedChanges()returnsfalserather than guessing. Resolve viasetMergedData()first.
Multiple pending drafts for the same record
The queue shows two or three pending drafts for one source row from the same user.
With autoSupersede => true (default): this shouldn't happen for the same (source, primary_key, user_id). If it does, check whether the plugin migration ran (the autoSupersede logic depends on indexes from the migration).
With autoSupersede => false: this is the documented behavior — old drafts stay pending. Either flip the option back on, or supersede manually:
$bouncerTable->supersedeOthers($source, $primaryKey, $keepId);$keepId is the proposal id you want to leave in pending; the rest get flipped to superseded.
Best practices
A short checklist that prevents most of the issues above:
- Always load drafts in edit actions —
loadDraft()first, then render. Prevents users overwriting their own proposals. - Distinguish bounced from saved in your UI — different flash message, different redirect.
wasBounced()makes this trivial. - Validate on draft (the default) — catch obvious errors early rather than at admin-review time.
- Wrap programmatic approvals in transactions — see Custom approval flow for the pattern.
- Wire status notifications — the proposer doesn't know their change was approved/rejected unless something tells them. See Status-change notifications.
- Pair with AuditStash — the approval workflow is one half of the story; the other half is the actual data changes. See AuditStash Integration.
- Test the bypass paths — exempt-user lists and bypass callbacks are exactly the kind of code that's silently wrong until it isn't.
- Document exemptions clearly — if
bypassCallbackexempts users based on role, leave a comment explaining why (audit trail of intent, not just code).