cjmellor/approval
Laravel package to stage new model changes for review before they’re persisted. Add approvals to your workflow, store pending records in the database, and promote them once approved. Supports PHP 8.3+ and Laravel 12.4+/13.
Installation:
composer require cjmellor/approval
php artisan vendor:publish --tag="approval-migrations"
php artisan migrate
Apply to a Model:
Add the MustBeApproved trait to your Eloquent model (e.g., Post):
use Cjmellor\Approval\Concerns\MustBeApproved;
class Post extends Model
{
use MustBeApproved;
}
First Use Case:
Create/update a Post instance. The package automatically:
approvals table.pending (default).creator_id/creator_type).Example:
Post::create(['title' => 'Draft Post', 'user_id' => 1]);
getApprovalForeignKeyName() if your model uses a non-standard key (e.g., author_id).config/approval.php to add states like in_review or needs_info.create()/update() for models with MustBeApproved.new_data (JSON) and original_data (JSON) in the approvals table.pending; transition via approve(), reject(), or postpone().Example Workflow:
// Admin approves a pending post
$approval = Approval::pending()->first();
$approval->approve(); // Fires `ModelApproved` event
// Reject with custom message
$approval->reject()->setCustomState('spam');
approveIf(), rejectUnless(), etc., for dynamic state changes:
$approval->approveIf($post->isFeatured());
$approval->rejectUnless(auth()->can('admin'));
$approval->expiresIn(hours: 48)->thenReject();
// routes/console.php
Schedule::command('approval:process-expired')->hourly();
$approval->rollback(); // Reverts to `original_data`, state = `pending`
$approval->rollback(bypass: false); // Forces re-approval
// Pending approvals by a user
Approval::pending()->requestedBy(auth()->user())->get();
// Expired but unprocessed
Approval::expired()->notExpired()->get();
ModelApproved, ModelRejected, or ApprovalExpired to trigger notifications/emails.withoutApproval() for admin-only updates or bulk operations:
Post::withoutApproval()->update(['status' => 'published']);
approvalAttributes:
class Post extends Model
{
use MustBeApproved;
protected array $approvalAttributes = ['title', 'content'];
}
Foreign Key Omission:
user_id), the approvals table will lack the foreign_key column, breaking requestor tracking.create()/update() calls.Missing creator_id:
creator_id defaults to the authenticated user. If no user is logged in, it may store null, complicating auditing.$approval->creator_id = $fallbackUserId;
JSON Column Limits:
JSON column limits (~64KB). Test with complex models.approvalAttributes to limit stored data or consider splitting attributes.Event Ordering:
ApprovalCreated fire after the approval record is saved. Avoid relying on event data for immediate validation.Expiration Edge Cases:
expires_at in the past may not trigger ApprovalExpired if the scheduler hasn’t run.php artisan approval:process-expired manually during testing.$approval->new_data->toArray(); // View JSON payload
$approval->original_data->diff($approval->new_data); // Compare changes
dd($approval->fresh()->state) to verify state changes after updates.whereHas('approval') on large datasets. Use direct Approval queries instead:
// Slow
Post::whereHas('approval')->get();
// Fast
Approval::where('approvalable_type', Post::class)->get();
Custom States:
config/approval.php and use them via setState():
$approval->setState('needs_info');
whereState('needs_info') works, but needsInfo() doesn’t).Custom Expiration Actions:
thenCustom() behavior by listening for ApprovalExpired:
event(new ApprovalExpired($approval));
Rollback Logic:
Approval model:
class CustomApproval extends \Cjmellor\Approval\Models\Approval
{
public function rollback(bool $bypass = true): self
{
// Custom logic (e.g., log rollback reason)
return parent::rollback($bypass);
}
}
Bulk Operations:
withoutApproval() for bulk updates to avoid flooding the approvals table:
Post::withoutApproval()->update(['status' => 'archived']);
pending state is marked as default in config. Remove this if you want all new approvals to start in a custom state.approvals table uses approvalable_type/approvalable_id. Ensure your models are properly namespaced (e.g., App\Models\Post).expires_at) use the server’s timezone. Set config('app.timezone') explicitly if needed.approvalable_type, approvalable_id, and state for large datasets:
Schema::table('approvals', function (Blueprint $table) {
$table->index(['approvalable_type', 'approvalable_id']);
$table->index('state');
});
Approval::expired()->limit(100)->get()->each(fn ($a) => $a->reject());
How can I help you explore Laravel packages today?