The bank-grade four-eyes principle: nothing high-stakes happens without two pairs of eyes. Refunds, role changes, large outbound payments, account closures, audit-relevant edits. Every Laravel app we ship for finance, healthcare or government grows a wonky bespoke approval system around month four.
moffhub/maker-checker is what we extracted so we'd stop writing it from scratch.
The whole thing in two lines
class Refund extends Model {
use \Moffhub\MakerChecker\Concerns\RequiresApproval;
protected static array $requiresApprovalFor = ['create', 'delete'];
protected static array $approvalRequirements = [
'create' => ['admin' => 1, 'finance' => 1],
'delete' => ['admin' => 2],
];
}
That's it. Now Refund::create([...]) returns false and quietly creates a pending MakerCheckerRequest instead. Refund::find(1)->delete() does the same. The model doesn't get persisted/deleted until the configured approvals land.
There's no facade call to remember, no service to inject. Add the trait, configure the rules, the framework intercepts via Eloquent's creating / updating / deleting model events.
When you genuinely need to bypass seeders, system events, jobs there's an explicit escape hatch:
Refund::createWithoutApproval([...]);
Refund::withoutApprovalDo(fn() => $refund->update(['status' => 'reconciled']));
The bypass is loud on purpose. Someone reviewing a PR can see when an approval was sidestepped.
Multi-role thresholds, tracked separately
['admin' => 2, 'finance' => 1] doesn't mean "any 3 approvals." It means two distinct admins plus one distinct finance user. The package tracks each role's approvals separately:
- 1st admin approves → request still pending (
admin: 1/2,finance: 0/1). - 2nd admin approves → still pending (
admin: 2/2 ✓,finance: 0/1). - Finance approves → all thresholds met, the underlying create/update/delete executes.
Approvals are parallel by default. Sequential mode (notifications.sequential = true) makes admins approve before finance is even notified a clear chain of responsibility for high-risk flows.
User-specific approvals (by email)
Sometimes you don't want "any finance manager" you want the CFO. By name. Even if their role changes tomorrow.
MakerChecker::request()
->toUpdate($invoice, ['amount' => 250_000])
->withRoleAndUserApprovals(
roles: ['admin' => 1],
users: ['cfo@company.com'],
)
->save();
The CFO's email is captured at request time. If they get a new role next week, the request still requires that email to approve. Compliant by construction.
Conditional rules driven by payload
Different actions need different review weight. A KES 1,000 refund is routine; KES 100,000 is a board-level conversation. Use the database config driver and store the rules as data, not code:
MakerCheckerConfig::create([
'configurable_type' => Refund::class,
'action' => 'create',
'approvals' => ['roles' => ['admin' => 1]],
'is_active' => true,
]);
MakerCheckerConfig::create([
'configurable_type' => Refund::class,
'action' => 'create',
'approvals' => ['roles' => ['admin' => 2, 'finance' => 1]],
'conditions' => [
'mode' => 'all',
'rules' => [
['field' => 'amount', 'operator' => '>=', 'value' => 50_000],
],
],
'is_active' => true,
]);
A new refund where amount >= 50_000 matches the second config and demands two admins plus one finance. Below that, one admin clears it. Operators include =, !=, >, >=, <, <=, in, not_in, contains, starts_with, between, regex. Conditions are AND/OR, nestable.
Compliance changes the threshold? Update a row. No deploy.
Delegation, with expiry
Approver going on leave. Without delegation, requests pile up.
$cfo->delegateApprovalAuthority(
to: $deputyCfo,
expiresAt: now()->addDays(7),
);
Until the expiry, $deputyCfo can approve anything that requires $cfo's role or specific email. Every delegated approval is tagged in the audit log so you can see who approved what under whose authority.
Pessimistic locking, race-condition free
Two approvers click "Approve" at the same moment. The naïve approach increments approvals_count twice; both think they're the second-to-last and the request approves with one approval missing. Or it executes twice.
The package wraps every approval in select ... for update on the request row. One transaction wins, processes the approval, commits. The second waits, re-reads, sees the new state, behaves correctly. No double-execution. No partial states.
Bulk approve
Inboxes pile up. The REST endpoint accepts a batch:
POST /api/maker-checker/requests/bulk-approve
{
"request_ids": [101, 102, 103, 104, 105],
"role": "admin"
}
One round-trip approves all five (those the caller has authority to approve, anyway). For an admin dashboard with twenty pending refunds, this is the difference between "manageable" and "abandoned."
REST API out of the box
GET /api/maker-checker/requests
GET /api/maker-checker/requests/{id}
POST /api/maker-checker/requests/{id}/approve
POST /api/maker-checker/requests/{id}/reject
POST /api/maker-checker/requests/{id}/cancel
GET /api/maker-checker/configs
POST /api/maker-checker/configs
POST /api/maker-checker/configs/{id}/enable | disable
POST /api/maker-checker/configs/test-conditions
Drop a Vue inbox in front, gate by role, ship. The API is auth:sanctum and rate-limited per the config.
Audit log that holds up in court
Every approval, rejection, cancellation and config change writes a row in maker_checker_audit_logs:
- the actor (morphTo, polymorphic) and their IP
- the previous and new statuses
- the request snapshot and metadata
- the timestamp
Export as JSON or CSV via the API. When a regulator asks "who approved this and when?", the answer is one query.
Custom executable actions
CRUD covers most cases. For the rest "approve a payout to mobile money," "approve the role grant," "approve the bulk SMS dispatch" extend ExecutableRequest:
class PayoutRequest extends ExecutableRequest {
public function execute(MakerCheckerRequest $request): void {
$payload = $request->payload;
Mpesa::b2c($payload['msisdn'], $payload['amount']);
}
public function uniqueBy(): array {
return ['msisdn', 'amount', 'reference'];
}
public function beforeApproval(MakerCheckerRequest $request): void {
// Pre-flight: balance check, KYC freshness, etc.
}
public function afterApproval(MakerCheckerRequest $request): void {
Notification::send(...);
}
public function onFailure(MakerCheckerRequest $request): void {
// Execute() threw escalate
}
}
MakerChecker::execute(PayoutRequest::class, [
'msisdn' => '+254712345678',
'amount' => 5000,
'reference' => 'PAYOUT-2026-0001',
]);
uniqueBy() prevents the same payout being submitted twice while the first is pending. beforeApproval runs after threshold met but before execute() last-mile validation. onFailure catches an execute() exception so you can escalate, alert, or log without losing the audit trail.
Auto-expiry
Pending requests gather dust. Configure a timeout and let the scheduler clean up:
'request_expiration_in_minutes' => 1440, // 24 hours
php artisan maker-checker:expire-requests
Run it on a schedule. Stale requests transition to expired; the maker is notified.
Install
composer require moffhub/maker-checker
php artisan vendor:publish --tag=maker-checker-config
php artisan migrate
Production in fintech, healthcare, government and an internal tool that we mostly built so the finance lead would stop emailing screenshots. The trait is the entire integration. Everything else is configuration.