afiqiqmal/approval-laravel
A lightweight Laravel package for adding approval workflows to Eloquent models. Attach approvers, track approval status, and gate actions until sign-off is complete. Simple configuration for apps that need a review or approval step before changes take effect.
composer require afiqiqmal/approval-laravel
# Publish config + migrations (provider path may differ by version)
php artisan vendor:publish --provider="Afiqiqmal\Approval\ApprovalServiceProvider"
php artisan migrate
Then make a model approvable:
use Afiqiqmal\Approval\Traits\Approvable;
class PurchaseOrder extends Model
{
use Approvable;
}
Where to look first, in this order:
status column on your table vs. a separate approvals table with one row per approver. A separate table means multi-approver and audit history; a column means a simple state flag. Read this before designing anything.Approvable trait — the actual public API surface (submit, approve, reject, scopes). Trait method names are the contract you'll be writing against daily, so skim them once rather than guessing.First use case worth wiring up: a purchase order that needs sign-off before it can be paid. Submit it, let an approver list only what's pending on them, record approve/reject, and gate the "Pay" action on the approved state. That single flow exercises submission, querying, transitions, and guarding — and it's the shape 80% of approval requirements take.
Submit / decide / inspect is the core loop. Keep it in a service or action class rather than sprinkling trait calls through controllers, because you'll almost always need to add notification, audit, and authorization around each transition:
class SubmitForApproval
{
public function __construct(private ApprovalNotifier $notifier) {}
public function handle(PurchaseOrder $order): void
{
DB::transaction(function () use ($order) {
$order->submitForApproval(); // confirm exact trait method name
$this->notifier->pending($order);
});
}
}
Query the approver's inbox with scopes, not loops. Whatever scope the package exposes for pending records, use it — and pair it with eager loading:
PurchaseOrder::query()
->pendingApproval() // package scope; verify name
->with(['approvals', 'requester'])
->latest()
->paginate(25);
Guard actions on status. The most common bug in approval features is UI-and-controller drift: the button disappears but the endpoint still accepts the POST. Enforce it server-side in a Policy or a canBePaid() check on the model, and reuse that same check in the Blade component.
Map package state to your domain state explicitly. Don't let approved leak into every if in your codebase. Add a small enum or accessor ($order->isPayable()) so that if the package's status strings change, or you later add a "partially approved" state, you change one file.
Chain multi-step approvals sequentially, not in parallel, unless the package explicitly supports parallel approvers. If there's an approvals table, ordering matters: approve step N only after step N−1. If the package has no built-in ordering, keep an order/sequence column and enforce it in your own action class rather than relying on row insertion order.
Hook notifications off transitions, not controllers. If the package fires events, listen to them; if it doesn't, your action classes are the seam. Either way, never send mail directly from the trait call site — you'll duplicate notifications when a retry happens.
Testing. Use RefreshDatabase, a factory state for each status, and assert both the happy path and the forbidden path (approving an already-approved record, rejecting without permission). Approval logic regressions are almost always "the guard was missing," so the negative tests carry the weight.
illuminate/* constraint, or a swapped underlying package (e.g. a new Symfony/mail/HTTP major). Before upgrading, diff composer.json in the tag, run composer why-not afiqiqmal/approval-laravel 2.0.0 against your app's lock, and read the compare link (1.6.0...2.0.0) — the release note alone tells you nothing about API changes. Run your approval test suite on a branch before merging.boot/booted or uses another package that hooks model events for the same trait, initialization silently wins or loses. If approvals stop firing after adding a second trait, look here first.lockForUpdate) or a conditional UPDATE ... WHERE status = 'pending' and check affected rows.NULL statuses that won't match pending. Backfill in the migration or treat NULL as a valid pre-approval state in your scopes.$order->status via a relation without eager loading will quietly double your query count on an inbox page. Add the relation to a $with default if it's read on every row.config/. Run php artisan config:clear and compare both files side by side.How can I help you explore Laravel packages today?