When to use this skill
Use this skill when working with model approval workflows, intercepting create/update operations for review, managing approval states, setting up time-based expirations, or handling approval events using cjmellor/approval.
Core Concepts
- Models use
MustBeApprovedtrait — Interceptscreatingandupdatingevents, storing dirty data in theapprovalstable instead of persisting directly. - Approvals have states —
Pending,Approved,Rejected(viaApprovalStatusenum), plus custom states defined in config. - Approve to persist — Data only reaches the model's table when explicitly approved.
- Bypass with
withoutApproval()— Skip the approval workflow for admin actions, seeders, etc. - Expiration support — Approvals can auto-reject, auto-postpone, or trigger custom logic after a deadline.
Setup
Add the MustBeApproved trait to any model that needs approval:
use Cjmellor\Approval\Concerns\MustBeApproved;
class Post extends Model
{
use MustBeApproved;
// Optional: only these attributes require approval, others persist immediately
protected array $approvalAttributes = ['title', 'content'];
}
Approving, Rejecting, and Postponing
use Cjmellor\Approval\Models\Approval;
// Query by state
$pending = Approval::pending()->get();
$approved = Approval::approved()->get();
$rejected = Approval::rejected()->get();
// Change state
Approval::where('id', 1)->approve(); // Persists new_data to the model's table
Approval::where('id', 2)->reject(); // Marks as rejected
Approval::where('id', 3)->postpone(); // Resets to pending
// Approve without persisting data (state change only)
Approval::where('id', 1)->approve(persist: false);
// Conditional helpers
$approval->approveIf($user->isAdmin());
$approval->rejectUnless($hasPermission);
$approval->postponeIf($needsMoreInfo);
Custom Approval States
Define custom states in config/approval.php:
'states' => [
'pending' => ['name' => 'Pending', 'default' => true],
'approved' => ['name' => 'Approved'],
'rejected' => ['name' => 'Rejected'],
'in_review' => ['name' => 'In Review'],
'needs_info' => ['name' => 'Needs Clarification'],
],
// Set any configured state
$approval->setState('in_review');
// Get current state (returns custom state if set, otherwise standard state)
$state = $approval->getState(); // 'in_review'
// Query by any state
Approval::whereState('in_review')->get();
Approval::whereState('pending')->get(); // Only genuinely pending, excludes custom states
Time-Based Expirations
// Set expiration with automatic action
$approval->expiresIn(hours: 48)->thenReject();
$approval->expiresIn(days: 7)->thenPostpone();
$approval->expiresIn(minutes: 30)->thenCustom(); // Handle via ApprovalExpired event listener
// Specific datetime
$approval->expiresIn(datetime: now()->addWeek());
// Check expiration
$approval->isExpired(); // bool
// Query by expiration
Approval::expired()->get();
Approval::notExpired()->get();
Approval::hasExpiration()->get();
Process expired approvals via the scheduler:
// In routes/console.php (Laravel 11+)
Schedule::command('approval:process-expired')->everyMinute();
Rollbacks
// Roll back an approved change (keeps Approved state by default)
$approval->rollback();
// Roll back and reset to Pending (requires re-approval)
$approval->rollback(bypass: false);
// Conditional rollback
$approval->rollback(condition: fn ($approval) => $approval->created_at->isToday());
Requestor / Creator Tracking
// Get who created the approval
$requestor = $approval->requestor; // The authenticated user at creation time
$creator = $approval->creator; // Same relationship, different name
// Filter by requestor
Approval::requestedBy($user)->get();
// Check if a specific user requested it
$approval->wasRequestedBy($user); // bool
Foreign Keys
// Default foreign key is 'user_id' — customize per model:
public function getApprovalForeignKeyName(): string
{
return 'author_id';
}
// Include the foreign key when creating:
Post::create(['title' => 'Hello', 'user_id' => auth()->id()]);
Events
All events extend ApprovalEvent with Approval $approval and ?Authenticatable $user:
| Event | When |
|---|---|
ApprovalCreated |
Approval request created |
ModelApproved |
Approval state set to approved |
ModelRejected |
Approval state set to rejected |
ModelSetPending |
Approval state set to pending (postponed) |
ModelRolledBack |
Approved change rolled back |
ApprovalExpired |
Expired approval processed by scheduler |
use Cjmellor\Approval\Events\ModelApproved;
class SendApprovalNotification
{
public function handle(ModelApproved $event): void
{
$approval = $event->approval;
$approver = $event->user;
// Send notification, log audit trail, etc.
}
}
Factory Support
Use the MustBeApprovedFactory trait in model factories to bypass approval in tests:
use Cjmellor\Approval\Concerns\MustBeApprovedFactory;
class PostFactory extends Factory
{
use MustBeApprovedFactory;
// ...
}
// In tests:
Post::factory()->withoutApproval()->create();
Bypassing Approval
// Bypass for a single operation
$post->withoutApproval()->update(['title' => 'Admin Override']);
// Check if bypass is active
$post->isApprovalBypassed(); // bool
How Data Flows
When a model with MustBeApproved is created or updated:
- The trait intercepts the
creating/updatingEloquent event - Dirty attributes are captured as
new_data, original values asoriginal_data - If
$approvalAttributesis defined, only those attributes go through approval — the rest persist immediately - The foreign key (default
user_id) is extracted and stored separately in theforeign_keycolumn - An
Approvalrecord is created withstate = pending - The original model operation is cancelled (returns
falsefrom the event) - When approved,
new_datais applied to the model viaforceFill()+withoutApproval()->save()
The new_data and original_data columns are cast to AsArrayObject, so you can access them as arrays:
$approval->new_data['title']; // The proposed new title
$approval->original_data['title']; // The original title before the change
Common Patterns
Building an Approval Dashboard
// Get all pending approvals with their related models
$approvals = Approval::pending()
->with('approvalable', 'requestor')
->latest()
->paginate(20);
// In a Blade view
@foreach ($approvals as $approval)
<div>
<p>{{ $approval->requestor?->name }} wants to change {{ class_basename($approval->approvalable_type) }}</p>
<p>Changes: {{ json_encode($approval->new_data->toArray()) }}</p>
<form method="POST" action="/approvals/{{ $approval->id }}/approve">
@csrf
<button type="submit">Approve</button>
</form>
</div>
@endforeach
Approval Controller
class ApprovalController extends Controller
{
public function approve(Approval $approval)
{
$approval->approve();
return back()->with('success', 'Changes approved and applied.');
}
public function reject(Approval $approval)
{
$approval->reject();
return back()->with('success', 'Changes rejected.');
}
}
Duplicate Detection
The package prevents duplicate pending approvals for the same model with identical data. If a user submits the same change twice, only one approval record is created.
JSON / Array Cast Attributes
Models with json or array casts work correctly. The approval system decodes JSON values when storing new_data and re-casts them when approving. Nested arrays and complex data structures are preserved.
Source: cjmellor/approval — distributed by TomeVault.