cjmellor/approval
Laravel package to stage and approve new Eloquent model data before it’s persisted. Provides an approval workflow with migrations and configurable behavior, supporting PHP 8.3+ and Laravel 12.4+/13.
Installation:
composer require cjmellor/approval
php artisan vendor:publish --tag="approval-migrations"
php artisan migrate
Apply Trait to Model:
use Cjmellor\Approval\Concerns\MustBeApproved;
class Post extends Model
{
use MustBeApproved;
}
First Use Case:
Create/update a Post model. The changes will automatically be stored in the approvals table with a pending state.
config/approval.php (customize states, foreign keys, etc.)isApprovalBypassed(), withoutApproval(), rollback()approved(), rejected(), pending(), requestedBy($user)ModelApproved, ModelRejected, ModelRolledBackModel Creation/Update:
$post = Post::create(['title' => 'Draft Post', 'user_id' => 1]);
// Creates an approval record in `pending` state.
Approval Process:
$approval = Approval::where('approvalable_type', Post::class)
->where('approvalable_id', $post->id)
->first();
$approval->approve(); // Persists changes to the model.
Conditional Actions:
$approval->approveIf($post->isReadyForReview());
$approval->rejectUnless($post->hasValidContent());
Foreign Keys:
Override getApprovalForeignKeyName() for non-standard keys (e.g., author_id).
public function getApprovalForeignKeyName(): string { return 'author_id'; }
Custom States:
Extend config/approval.php for workflows like in_review or needs_info.
'states' => [
'in_review' => ['name' => 'In Review'],
],
Expiration Handling:
Schedule the approval:process-expired command in App\Console\Kernel.php:
$schedule->command('approval:process-expired')->everyMinute();
Partial Approvals:
Use approvalAttributes to restrict approvals to specific fields:
protected array $approvalAttributes = ['title', 'content'];
Rollbacks: Revert changes and reset state:
$approval->rollback(); // Reverts to original data, sets state to `pending`.
Listen for lifecycle events to trigger notifications or audits:
use Cjmellor\Approval\Events\ModelApproved;
ModelApproved::listen(function ($model) {
Notification::send($model->user, new ApprovalGranted($model));
});
Missing Foreign Keys:
user_id) will lack a creator_id in the approvals table.create() calls or override getApprovalForeignKeyName().Schema Migrations:
UPGRADE.md).Polymorphic Conflicts:
approvalable_type but different foreign_key values may cause ambiguity in queries.where('approvalable_type', Model::class)->where('foreign_key', $value) for precision.Expiration Quirks:
thenCustom() for expired approvals requires manual handling via ApprovalExpired events.ApprovalExpired and implement custom logic (e.g., notifications).Bypass Misuse:
withoutApproval() bypasses all approval checks, including custom attributes.approvalAttributes for granular control.Inspect Approval Data:
$approval = Approval::find($id);
dd($approval->new_data->toArray(), $approval->original_data->toArray());
Check State Transitions:
$approval->fresh()->state; // Verify current state after updates.
Query Performance:
Avoid whereHas('approval') on large datasets. Use direct approvals table queries:
Approval::where('approvalable_type', Post::class)->get();
Custom Approval Logic:
Override the shouldBeApproved() method in your model to dynamically enable/disable approvals:
public function shouldBeApproved(): bool
{
return $this->isPublished() === false;
}
State Validation:
Extend the ApprovalStatus enum (via config/approval.php) to add validation rules:
'states' => [
'draft' => ['name' => 'Draft', 'valid_transitions' => ['pending', 'rejected']],
],
Rollback Callbacks:
Listen for ModelRolledBack to trigger side effects (e.g., log changes):
ModelRolledBack::listen(function ($approval) {
Log::info("Rolled back {$approval->approvalable_type} ID {$approval->approvalable_id}");
});
Custom Expiration Actions:
Create a service to handle thenCustom() logic:
ApprovalExpired::listen(function ($approval) {
if ($approval->expiration_action === 'custom') {
$this->handleCustomExpiry($approval);
}
});
Default States:
The pending state is always the default. Omitting default: true in config/approval.php will not change this.
Enum Casting:
The state column uses Laravel’s ApprovalStatus enum. Direct string assignments (e.g., state = 'approved') won’t work; use:
$approval->setState('approved'); // Method call required.
JSON Columns:
new_data and original_data are cast to AsArrayObject. Use ->toArray() to convert to native arrays:
$data = $approval->new_data->toArray();
How can I help you explore Laravel packages today?