cjmellor/approval
Approval is a Laravel package that provides a simple way to approve new Model data before it is persisted. Add an approval workflow to any Eloquent model, keeping unapproved changes out of your database until reviewed. Requires PHP 8.3+ and Laravel 12.4+.
Install and wire up (5 minutes)
composer require cjmellor/approval
php artisan vendor:publish --tag="approval-migrations"
php artisan migrate
php artisan vendor:publish --tag="approval-config" # optional, but do it
Check your constraints first: this needs PHP 8.3+ and Laravel 12.4+ or 13. The 12.4 floor is real — the package uses the #[Scope] attribute introduced in that release. If you're on Laravel 11, pin ^2.0 and plan the upgrade, because v2.1 dropped it outright.
First use case: a moderation queue for a Post
use Cjmellor\Approval\Concerns\MustBeApproved;
class Post extends Model
{
use MustBeApproved;
}
That's the whole integration. Now Post::create([...]) and $post->update([...]) stop being "just writes" — the dirty payload lands in the approvals table as JSON (new_data, plus original_data when updating), with a polymorphic pointer back to the model via approvalable_type / approvalable_id.
Where to look first
config/approval.php — three keys you'll touch: approval_pivot, users_table, and states. The states array is the one that shapes your whole workflow.approvals migration — read the column list. The ones that matter day-to-day are state, new_data, original_data, foreign_key, creator_id/creator_type, audited_by, and the expiry trio (expires_at, expiration_action, actioned_at).Cjmellor\Approval\Models\Approval — your entry point for everything: Approval::pending(), ->approve(), ->reject(), ->rollback(), ->expiresIn().Your first screen, roughly:
// Controller
$pending = Approval::pending()
->with('approvalable') // the model awaiting approval
->latest()
->paginate(25);
// Approve
Approval::findOrFail($id)->approve();
Then build the part the package deliberately doesn't: applying new_data to the model. See below — this is the single most important thing to internalise about this package.
The README is explicit: this package does not approve/deny the data for you, it just stores it. So your approve() action is two operations — apply, then mark.
public function approve(Approval $approval): void
{
DB::transaction(function () use ($approval) {
$model = $approval->approvalable;
$model->forceFill($approval->new_data->toArray())->saveQuietly();
$approval->approve(); // sets state + audited_by
});
}
saveQuietly() (or withoutApproval(), see below) is essential here — otherwise re-saving the model re-enters the approval pipeline and you get an infinite queue.
original_dataBecause updates capture the pre-change snapshot, you get a free before/after view. Build a small presenter:
$changes = collect($approval->new_data)
->map(fn ($new, $key) => [
'field' => $key,
'from' => $approval->original_data[$key] ?? null,
'to' => $new,
])
->values();
This is where the package earns its keep versus hand-rolling an approval_requests table: the AsArrayObject casts make the payload queryable and diffable without any serialization work on your side.
For "trusted authors skip the queue" logic, use the *If/*Unless helpers rather than branching around approve():
$approval->approveIf($post->author->is_trusted);
$approval->rejectIf($post->containsBannedWords());
$approval->postponeIf($post->needs_legal_review);
Wire this into an ApprovalCreated listener so it fires the moment the request is made, not on a nightly cron. That listener is also the natural place to notify moderators.
Default pending/approved/rejected is too coarse for anything with a review stage. Add to config/approval.php:
'states' => [
'pending' => ['name' => 'Pending', 'default' => true],
'in_review' => ['name' => 'In Review'],
'needs_info'=> ['name' => 'Needs Clarification'],
'approved' => ['name' => 'Approved'],
'rejected' => ['name' => 'Rejected'],
],
Then $approval->setState('in_review'), $approval->getState(), and query with Approval::whereState('in_review'). The named scopes (pending(), approved(), rejected()) keep working, so existing code doesn't break. Always branch on getState(), not on the enum cast, once custom states are in play — the custom value is stored separately in custom_state.
Every approval knows who asked: $approval->requestor, Approval::requestedBy($user), $approval->wasRequestedBy($user). Use these to scope the queue — a team lead sees their team's requests, an admin sees everything. audited_by (populated from users_table in config) tells you who actioned it, which is your audit trail.
Override the foreign key per model when user_id isn't it:
public function getApprovalForeignKeyName(): string
{
return 'author_id';
}
This is the most underrated feature. Set an expiry, choose the fallback:
Approval::find($id)->expiresIn(hours: 48)->thenReject();
Approval::find($id)->expiresIn(days: 7)->thenPostpone(); // back to pending
Approval::find($id)->expiresIn(hours: 4)->thenCustom(); // you handle it
And schedule the processor — nothing happens otherwise:
// routes/console.php
Schedule::command('approval:process-expired')->everyMinute();
thenCustom() + an ApprovalExpired listener is your escape hatch for escalation ("auto-assign to senior editor"). Note the event fires then the action event (ModelRejected, ModelSetPending), so ordering matters in listeners.
Three distinct escape hatches, use each for its purpose:
$model->withoutApproval()->update([...]); // runtime bypass — seeders, imports, admin overrides
$model->isApprovalBypassed(); // introspect whether it would be bypassed
protected array $approvalAttributes = ['title', 'body']; // only these fields enter the pipeline
approvalAttributes is the right call on models where 90% of columns are counters, timestamps, or internal flags — otherwise your moderation queue fills with noise.
Approval::first()->rollback(); // revert + set pending (bypassed by default)
Approval::first()->rollback(bypass: false); // revert, but re-enter the queue
Approval::first()->rollback(fn () => $user->isAdmin()); // conditional
rollback() restores original_data and stamps rolled_back_at, which is what makes this useful for post-hoc corrections rather than a destructive undo. Fire-and-forget in an admin action, guarded by a policy check.
Your test suite will fight you the moment the trait is on. Standard approach:
// In a base TestCase or per-test
$post->withoutApproval()->update(['title' => 'Test']);
// Or assert the queue instead of the model
$this->assertDatabaseHas('approvals', [
'approvalable_id' => $post->id,
'state' => 'pending',
]);
Keep at least a couple of tests that assert entering the queue — that behaviour is the contract with your users, and it silently breaks if you refactor around saveQuietly().
The package never applies your data. This is the #1 source of "the package is broken" issues. approve() flips a state column. Reading new_data and writing it to the model is your code. Budget for that work up front — a queue screen, an apply action, and a diff view is a day, not an hour.
Foreign keys are the second trap. The default is user_id, and it's only captured if it's in the payload:
Post::create(['title' => 'Some Title']); // foreign_key => null
Post::create(['title' => 'Some Title', 'user_id' => 1]); // foreign_key => 1
So either always include it, set a model-level default, or override getApprovalForeignKeyName(). A null foreign_key means you can't route or filter the queue by owner later, and backfilling is painful.
foreign_key and creator_id are different things. foreign_key is the model's own FK (e.g. author_id); creator_id/creator_type identify who requested the approval — which may be an admin acting on behalf of the author. Don't conflate them in your UI.
Re-saving inside your approve handler re-queues the model. Use saveQuietly() or withoutApproval(). If your queue mysteriously refills itself after every approval, this is why.
Only dirty attributes are captured. Bulk operations, query()->update(), and increment() bypass Eloquent events entirely — they won't create approvals and won't warn you. If compliance matters, forbid raw query-builder writes on approval-gated tables in code review.
rollback() bypasses re-approval by default. bypass: true is the default, so a rollback restores data and doesn't push it back through the queue. For a regulated domain you almost certainly want rollback(bypass: false).
Expirations are decorative without the scheduler. expires_at gets set, isExpired() returns true, and nothing happens until approval:process-expired runs. Add the Schedule::command(...) line as part of installation, not as a follow-up ticket.
Upgrading from v1 requires ordered commands. v2 changed the schema. Follow UPGRADE.md literally — the ordering of the publish/migrate/backfill steps is load-bearing, and skipping ahead leaves you with a half-migrated approvals table that's annoying to repair.
Bulk state changes hit the whole builder. Approval::where('id', 1)->approve() looks like it targets one row, but approve() on a builder is a mass update. Double-check your where clauses before shipping an admin action.
Listeners are synchronous by default. ApprovalCreated, ModelApproved, ModelRejected, ModelSetPending, ModelRolledBack, ApprovalExpired all fire inline. Moderator notification emails belong in a queued listener or your approve request will feel slow.
Guard concurrency on approve(). Two moderators clicking approve simultaneously will both apply new_data. Wrap apply+set-state in a transaction and consider a lockForUpdate() on the approval row — cheap insurance, especially if the apply step touches relations.
AsArrayObject casting bites on nested mutations. Mutating $approval->new_data['x'] = 'y' in place may not register as dirty depending on nesting depth. Read with ->toArray() in your apply step and explicitly save() if you ever write back to it.
Don't put large text/blob columns behind approval. Every edit snapshots the column twice (new_data + original_data) as JSON. A body column with 200KB of HTML means 400KB per pending edit, plus row growth in the approvals table forever. Either scope with approvalAttributes or handle those fields out of band.
Extension points worth knowing: states in config (custom workflow), getApprovalForeignKeyName() (multi-tenant routing), approvalAttributes (selective gating), withoutApproval() (system operations), and the event set (integration surface). Everything else you'll be building yourself — which is fine, because that's exactly where the package stops and your domain begins.
When not to reach for this package: if you need versioned drafts with rich diffs, per-field approvals, or multi-stage sign-off chains, the single-row-per-save model will strain. It's an excellent 80% solution for "posts need review before going live"; it is not a document-management system.
How can I help you explore Laravel packages today?