moffhub/maker-checker
Feature-complete maker-checker (four-eyes) approvals for Laravel. Add a trait to intercept model create/update/delete, or use the API for complex workflows: multi-level/role & user approvals, conditional rules, delegation, bulk ops, reminders/escalation, audit trail & export.
The most feature-complete maker-checker (four-eyes principle) approval workflow package for Laravel. Add multi-level approval requirements to any Eloquent model with a single trait, or use the full-featured API for complex enterprise workflows.
Unlike simpler approval packages, this supports multi-role approvals, a conditional rules engine, approval delegation, bulk operations, audit trail with export, reminders & escalation, and auto-intercept via Eloquent model events -- all out of the box.
| Feature | moffhub/maker-checker | Others |
|---|---|---|
| Auto-intercept via trait | ✅ | Some |
| Multi-role approvals (2 admins + 1 manager) | ✅ | Rare |
| User-specific approvals | ✅ | ❌ |
| Conditional rules engine (if amount > 50K...) | ✅ | ❌ |
| Database-driven config (change rules at runtime) | ✅ | ❌ |
| Execute arbitrary actions (not just CRUD) | ✅ | ❌ |
| Approval delegation with expiry | ✅ | ❌ |
| Bulk approve endpoint | ✅ | ❌ |
| Reminders & escalation | ✅ | ❌ |
| Audit trail + CSV/JSON export | ✅ | Rare |
| Race condition safe (pessimistic locking) | ✅ | ❌ |
| REST API included | ✅ | ❌ |
| 360+ tests | ✅ | Varies |
RequiresApproval trait to any model; create/update/delete are intercepted automaticallyInstall the package via Composer:
composer require moffhub/maker-checker
Publish and run the migrations:
php artisan vendor:publish --tag=maker-checker-migrations
php artisan migrate
Optionally publish the config file:
php artisan vendor:publish --tag=maker-checker-config
The simplest way to add maker-checker to your models is using the RequiresApproval trait. This automatically intercepts create, update, and delete operations:
use Illuminate\Database\Eloquent\Model;
use Moffhub\MakerChecker\Traits\RequiresApproval;
class Post extends Model
{
use RequiresApproval;
// Optionally specify which actions require approval
protected static array $requiresApprovalFor = ['create', 'delete'];
// Optionally define approval requirements
protected static array $approvalRequirements = [
'create' => ['editor' => 1],
'delete' => ['admin' => 2],
];
}
Now when you try to create or delete a Post, the operation returns false and a pending approval request is created:
$post = new Post(['title' => 'My Post', 'user_id' => auth()->id()]);
$saved = $post->save();
if (!$saved && Post::wasIntercepted()) {
$request = Post::getInterceptedRequest();
return response()->json([
'message' => 'Your request has been submitted for approval.',
'request_id' => $request->id,
'request_code' => $request->code,
], 202); // HTTP 202 Accepted
}
// To bypass approval (for admin operations, seeders, etc.):
$post = Post::createWithoutApproval(['title' => 'Direct Create']);
// Or use the callback method:
Post::withoutApprovalDo(function () {
Post::create(['title' => 'Also bypassed']);
});
Use the MakerChecker facade with convenience methods that auto-inject the authenticated user:
use Moffhub\MakerChecker\Facades\MakerChecker;
// Create a request (auto-injects auth user as maker)
$request = MakerChecker::create(Post::class, ['title' => 'My Post']);
// With custom description
$request = MakerChecker::create(Post::class, ['title' => 'My Post'], 'Create a new blog post');
// Update a model
$request = MakerChecker::update($post, ['title' => 'Updated Title']);
// Delete a model
$request = MakerChecker::delete($post);
// Execute a custom action
$request = MakerChecker::execute(TransferFunds::class, ['amount' => 5000]);
Approve, reject, or cancel requests (also auto-injects auth user):
// Approve (uses authenticated user)
MakerChecker::approve($request);
MakerChecker::approve($request, null, 'admin'); // With role
MakerChecker::approve($request, null, 'admin', 'LGTM'); // With role and remarks
// Or with explicit user
MakerChecker::approve($request, $approver, 'admin');
// Reject
MakerChecker::reject($request);
MakerChecker::reject($request, null, 'Missing information');
// Cancel (only maker can cancel)
MakerChecker::cancel($request);
You can also call these methods directly on the request model:
$request->approve(); // Uses auth user
$request->approve(null, 'admin'); // With role
$request->approve($user, 'admin', 'Approved'); // Explicit user
$request->reject(null, 'Not approved');
$request->cancel();
For advanced usage with hooks and custom configuration:
use Moffhub\MakerChecker\Facades\MakerChecker;
$request = MakerChecker::request()
->toCreate(Post::class, ['title' => 'My Post'])
->madeBy(auth()->user())
->description('Create a new blog post')
->withApprovals(['editor' => 1, 'admin' => 1])
->beforeApproval(fn($r) => Log::info('Approving...'))
->afterApproval(fn($r) => Notification::send(...))
->save();
Add the MakerCheckerUserContract interface to your User model:
use Moffhub\MakerChecker\Contracts\MakerCheckerUserContract;
class User extends Authenticatable implements MakerCheckerUserContract
{
public function hasMakerCheckerPermission(string $permission): bool
{
return $this->hasPermission($permission); // Your permission logic
}
public function getMakerCheckerTeamId(): ?int
{
return $this->team_id; // For multi-tenancy, or null
}
public function getMakerCheckerRole(): ?string
{
return $this->role; // e.g., 'admin', 'manager'
}
public function getMakerCheckerEmail(): ?string
{
return $this->email;
}
}
Use the MakerChecker facade to create approval requests:
use Moffhub\MakerChecker\Facades\MakerChecker;
use App\Models\Post;
// Create request for a new Post
$request = MakerChecker::request()
->toCreate(Post::class, [
'title' => 'My New Post',
'content' => 'Post content here...',
'user_id' => auth()->id(),
])
->madeBy(auth()->user())
->description('Create a new blog post')
->save();
use Moffhub\MakerChecker\Facades\MakerChecker;
// Simple - uses authenticated user automatically
MakerChecker::approve($request);
MakerChecker::reject($request, null, 'Missing required information');
// With role (for multi-role approvals)
MakerChecker::approve($request, null, 'admin', 'Looks good!');
// With explicit user
MakerChecker::approve($request, $approverUser, 'admin', 'Looks good!');
// Or call directly on the request model
$request->approve();
$request->approve(null, 'admin');
$request->reject(null, 'Not approved');
$request->cancel(); // Only maker can cancel
MakerChecker::request()
->toCreate(Post::class, ['title' => 'New Post', 'content' => '...'])
->withApprovals(['admin' => 1])
->madeBy(auth()->user())
->save();
MakerChecker::request()
->toUpdate($post, ['title' => 'Updated Title'])
->madeBy(auth()->user())
->save();
MakerChecker::request()
->toDelete($post)
->withApprovals(['admin' => 2]) // Require 2 admin approvals
->madeBy(auth()->user())
->save();
For complex operations, create an executable class:
use Moffhub\MakerChecker\Contracts\ExecutableRequest;
use Moffhub\MakerChecker\Models\MakerCheckerRequest;
class TransferFunds extends ExecutableRequest
{
public function execute(MakerCheckerRequest $request): void
{
$payload = $request->payload;
// Perform the transfer
BankService::transfer(
from: $payload['from_account'],
to: $payload['to_account'],
amount: $payload['amount']
);
}
public function uniqueBy(): array
{
return ['from_account', 'to_account', 'amount'];
}
public function beforeApproval(MakerCheckerRequest $request): void
{
// Validate accounts still exist
}
public function afterApproval(MakerCheckerRequest $request): void
{
// Send notification
}
public function onFailure(MakerCheckerRequest $request): void
{
// Handle failure
}
}
Then create the request:
MakerChecker::request()
->toExecute(TransferFunds::class, [
'from_account' => 'ACC001',
'to_account' => 'ACC002',
'amount' => 5000,
])
->withApprovals(['finance' => 1, 'manager' => 1])
->madeBy(auth()->user())
->save();
Require approvals from multiple roles:
MakerChecker::request()
->toCreate(User::class, $userData)
->withApprovals([
'hr' => 1, // 1 HR approval
'admin' => 2, // 2 Admin approvals
'manager' => 1, // 1 Manager approval
])
->madeBy(auth()->user())
->save();
Check approval status:
$request->getApprovalCount(); // Total approvals received
$request->getPendingRoles(); // ['admin' => 1, ...] remaining
$request->hasMetApprovalThreshold(); // true/false
In addition to role-based approvals, you can require specific users to approve a request. This is useful when you need approval from a particular person regardless of their role.
Specify users by email or ID:
// Require approval from a specific user by email
MakerChecker::request()
->toCreate(Contract::class, $data)
->requiringUsersToApprove(['cfo@company.com'])
->madeBy(auth()->user())
->save();
// Require approval from multiple specific users
MakerChecker::request()
->toCreate(Contract::class, $data)
->requiringUsersToApprove(['cfo@company.com', 'ceo@company.com'])
->madeBy(auth()->user())
->save();
// Require approval from user by ID
MakerChecker::request()
->toCreate(Contract::class, $data)
->requiringUsersToApprove([(string) $cfoUser->id])
->madeBy(auth()->user())
->save();
Require both role-based and user-specific approvals:
// Requires 1 admin approval AND approval from the CFO
MakerChecker::request()
->toCreate(Contract::class, $data)
->withRoleAndUserApprovals(
roles: ['admin' => 1],
users: ['cfo@company.com']
)
->madeBy(auth()->user())
->save();
By default, the package validates that all specified users exist in the system before creating the request:
// This will throw an exception if the user doesn't exist
MakerChecker::request()
->toCreate(Contract::class, $data)
->requiringUsersToApprove(['nonexistent@company.com'])
->madeBy(auth()->user())
->save();
// Throws: RequestCouldNotBeInitiated
// Disable validation if needed (not recommended)
MakerChecker::request()
->toCreate(Contract::class, $data)
->requiringUsersToApprove(['future@company.com'], validateExistence: false)
->madeBy(auth()->user())
->save();
$request->requiresUserApprovals(); // true if users are required
$request->getPendingUsers(); // ['cfo@company.com', ...] remaining
When a user-specific approval is required, only the specified users can approve:
$request = MakerChecker::request()
->toCreate(Contract::class, $data)
->requiringUsersToApprove(['cfo@company.com'])
->madeBy(auth()->user())
->save();
// Only the CFO can approve - other users will get an error
MakerChecker::approve($request, $cfoUser, 'user'); // Works
MakerChecker::approve($request, $otherUser); // Throws exception
Implement MakerCheckerConfigurable on your models:
use Moffhub\MakerChecker\Contracts\MakerCheckerConfigurable;
use Moffhub\MakerChecker\Enums\RequestType;
class Post extends Model implements MakerCheckerConfigurable
{
public static function makerCheckerApprovals(): array
{
return [
'create' => ['editor' => 1],
'update' => ['editor' => 1],
'delete' => ['admin' => 1, 'editor' => 1],
];
}
public static function makerCheckerUniqueFields(): array
{
return [
'create' => ['title', 'slug'],
];
}
public static function requiresMakerChecker(RequestType $action): bool
{
// Only require approval for delete
return $action === RequestType::DELETE;
}
public static function makerCheckerDescription(RequestType $action, array $payload): string
{
return match($action) {
RequestType::CREATE => "Create post: {$payload['title']}",
RequestType::UPDATE => "Update post",
RequestType::DELETE => "Delete post",
default => "Post operation",
};
}
}
Configure in config/maker-checker.php:
'models' => [
App\Models\User::class => [
'approvals' => [
'create' => ['hr' => 1, 'admin' => 1],
'update' => ['admin' => 1],
'delete' => ['admin' => 2],
],
'unique_fields' => [
'create' => ['email'],
],
'required_for' => ['create', 'delete'],
],
],
'executables' => [
App\MakerChecker\TransferFunds::class => [
'approvals' => ['finance' => 1, 'manager' => 1],
'unique_fields' => ['from_account', 'to_account', 'amount'],
],
],
'global_approvals' => [
'create' => ['admin' => 1],
'update' => ['admin' => 1],
'delete' => ['admin' => 2],
'execute' => ['admin' => 1],
],
Enable the database driver for dynamic configuration:
// config/maker-checker.php
'config_driver' => 'database',
Manage configs via API or programmatically:
use Moffhub\MakerChecker\Models\MakerCheckerConfig;
// Create with role-based approvals
MakerCheckerConfig::create([
'configurable_type' => Post::class,
'action' => 'delete',
'approvals' => [
'roles' => ['admin' => 2],
],
'is_active' => true,
]);
// Create with both role and user approvals
MakerCheckerConfig::create([
'configurable_type' => Contract::class,
'action' => 'create',
'approvals' => [
'roles' => ['admin' => 1, 'legal' => 1],
'users' => ['cfo@company.com', 'ceo@company.com'],
],
'description' => 'High-value contracts require CFO and CEO approval',
'is_active' => true,
]);
// Create with user-only approvals
MakerCheckerConfig::create([
'configurable_type' => Payment::class,
'action' => 'create',
'approvals' => [
'users' => ['finance@company.com'],
],
'is_active' => true,
]);
Create configuration via API:
POST /api/maker-checker/configs
Content-Type: application/json
{
"configurable_type": "App\\Models\\Contract",
"action": "create",
"approvals": {
"roles": {
"admin": 1,
"legal": 1
},
"users": [
"cfo@company.com",
"ceo@company.com"
]
},
"description": "Contract creation approval workflow"
}
Response:
{
"message": "Configuration created successfully",
"data": {
"id": 1,
"configurable_type": "App\\Models\\Contract",
"configurable_name": "Contract",
"action": "create",
"action_label": "Create",
"approvals": {
"roles": {"admin": 1, "legal": 1},
"users": ["cfo@company.com", "ceo@company.com"]
},
"role_approvals": {"admin": 1, "legal": 1},
"user_approvals": ["cfo@company.com", "ceo@company.com"],
"requires_user_approvals": true,
"is_active": true
}
}
Update configuration:
PUT /api/maker-checker/configs/1
Content-Type: application/json
{
"approvals": {
"roles": {"admin": 2},
"users": ["cfo@company.com"]
}
}
A sample UI mockup for the configuration management interface is available at docs/ui-mockup.html. Open it in a browser to see how the frontend could interact with these APIs.
The package provides RESTful API endpoints (prefix configurable):
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/maker-checker/requests |
List all requests |
| GET | /api/maker-checker/requests/{id} |
Get request details |
| GET | /api/maker-checker/requests/{id}/approvals |
Get approval status |
| POST | /api/maker-checker/requests/{id}/approve |
Approve a request |
| POST | /api/maker-checker/requests/{id}/reject |
Reject a request |
| POST | /api/maker-checker/requests/{id}/cancel |
Cancel own request |
| GET | /api/maker-checker/requests/statistics |
Get request statistics |
| GET | /api/maker-checker/requests/statuses |
List available statuses |
GET /api/maker-checker/requests?status=pending&type=create&team_id=1
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/maker-checker/configs |
List all configs |
| POST | /api/maker-checker/configs |
Create config |
| GET | /api/maker-checker/configs/{id} |
Get config |
| PUT | /api/maker-checker/configs/{id} |
Update config |
| DELETE | /api/maker-checker/configs/{id} |
Delete config |
| POST | /api/maker-checker/configs/{id}/enable |
Enable config |
| POST | /api/maker-checker/configs/{id}/disable |
Disable config |
| GET | /api/maker-checker/configs/export |
Export all configs |
| POST | /api/maker-checker/configs/import |
Import configs |
// config/maker-checker.php
'routes' => [
'enabled' => true,
'prefix' => 'api',
'middleware' => ['api', 'auth:sanctum'],
],
To customize routes, publish them:
php artisan vendor:publish --tag=maker-checker-routes
| Status | Description |
|---|---|
pending |
Awaiting first approval |
partially_approved |
Has some approvals but not all required |
approved |
Fully approved and executed |
rejected |
Rejected by a checker |
cancelled |
Cancelled by the maker |
expired |
Expired after timeout |
failed |
Execution failed |
Add hooks to the request builder:
MakerChecker::request()
->toCreate(Post::class, $data)
->beforeApproval(function ($request) {
Log::info('About to approve', ['id' => $request->id]);
})
->afterApproval(function ($request) {
Notification::send($request->maker, new RequestApproved($request));
})
->beforeRejection(function ($request) {
// Cleanup logic
})
->afterRejection(function ($request) {
Notification::send($request->maker, new RequestRejected($request));
})
->onFailure(function ($request) {
Log::error('Request failed', ['id' => $request->id]);
})
->madeBy(auth()->user())
->save();
The RequiresApproval trait provides automatic interception of Eloquent model events. When added to a model, create/update/delete operations return false and create a pending approval request instead of executing immediately.
use Moffhub\MakerChecker\Traits\RequiresApproval;
class Transaction extends Model
{
use RequiresApproval;
}
class Transaction extends Model
{
use RequiresApproval;
// Only intercept these actions (default: all)
protected static array $requiresApprovalFor = ['create', 'delete'];
// Define approval requirements per action
protected static array $approvalRequirements = [
'create' => ['finance' => 1],
'update' => ['finance' => 1],
'delete' => ['finance' => 1, 'manager' => 1],
];
}
// Method 1: Static method (resets after one operation)
Transaction::withoutApproval();
Transaction::create([...]); // Bypassed
Transaction::create([...]); // Requires approval again
// Method 2: Instance methods
$transaction = Transaction::createWithoutApproval([...]);
$transaction->updateWithoutApproval(['amount' => 500]);
$transaction->deleteWithoutApproval();
// Method 3: Callback (recommended for multiple operations)
Transaction::withoutApprovalDo(function () {
Transaction::create([...]);
Transaction::create([...]);
// All operations inside are bypassed
});
When an operation is intercepted, the model's save() or delete() returns false. Check if it was intercepted and get the pending request:
$transaction = new Transaction($data);
$saved = $transaction->save();
if (!$saved && Transaction::wasIntercepted()) {
$request = Transaction::getInterceptedRequest();
return response()->json([
'message' => 'Pending approval',
'request_id' => $request->id,
'request_code' => $request->code,
], 202); // HTTP 202 Accepted
}
// Clear the intercepted state after handling
Transaction::clearInterceptedRequest();
If you prefer exception-based handling:
// Enable exception mode
Transaction::throwOnIntercept(true);
try {
$transaction = Transaction::create($data);
} catch (PendingApprovalException $e) {
$request = $e->getRequest();
return response()->json(['request' => $request->toArray()], 202);
}
$transaction = Transaction::find(1);
// Check if there are pending approvals
$transaction->hasPendingApproval(); // Any action
$transaction->hasPendingApproval(RequestType::DELETE); // Specific action
// Get pending approval requests
$pending = $transaction->getPendingApprovals();
$pendingDeletes = $transaction->getPendingApprovals(RequestType::DELETE);
By default, the trait uses auth()->user() as the maker. You can override this:
// Set a specific user as the maker
Transaction::setApprovalMaker($adminUser);
// Operations will use $adminUser as the maker
Transaction::create([...]);
// Reset to use auth()->user() again
Transaction::setApprovalMaker(null);
Query requests visible to a user:
use Moffhub\MakerChecker\Models\MakerCheckerRequest;
// Get requests visible to current user
$requests = MakerCheckerRequest::visibleTo(auth()->user())->get();
// Users with 'view_any_permission' see all requests
// Others see only their own or their team's requests
Enable automatic expiration:
// config/maker-checker.php
'request_expiration_in_minutes' => 1440, // 24 hours
Run the expiration command (add to scheduler):
// app/Console/Kernel.php
$schedule->command('maker-checker:expire-requests')->hourly();
The package can automatically notify approvers when a new request is pending, and notify makers when their request is approved or rejected.
// config/maker-checker.php
'notifications' => [
'enabled' => true,
'channels' => ['mail', 'database'], // Notification channels
'notify_maker' => true, // Notify maker of approval/rejection
'sequential' => false, // See "Sequential Notifications" below
'user_model' => App\Models\User::class,
'role_attribute' => 'role', // Attribute containing user's role
],
The package uses an ApproverResolver to find users who can approve requests. The default resolver queries users by role attribute:
// Default behavior: finds users where role = 'admin'
// When request requires ['admin' => 2], finds all users with role 'admin'
For more complex scenarios (Spatie permissions, team-based roles, etc.), implement your own resolver:
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Moffhub\MakerChecker\Contracts\ApproverResolver;
use Moffhub\MakerChecker\Models\MakerCheckerRequest;
class CustomApproverResolver implements ApproverResolver
{
public function getApproversForRole(MakerCheckerRequest $request, string $role): Collection
{
// Custom logic: Spatie permissions, team filtering, etc.
return User::role($role)
->where('team_id', $request->team_id)
->where('id', '!=', $request->maker_id)
->get();
}
public function getAllApprovers(MakerCheckerRequest $request): Collection
{
// Get all users who can approve any role or are specifically required
$requiredApprovals = $request->required_approvals ?? [];
$roles = $requiredApprovals['roles'] ?? $requiredApprovals;
$users = $requiredApprovals['users'] ?? [];
$approvers = User::role(array_keys($roles))->get();
if (!empty($users)) {
$specificUsers = $this->getApproversByIdentifier($request, $users);
$approvers = $approvers->merge($specificUsers)->unique('id');
}
return $approvers;
}
public function getApproversByIdentifier(MakerCheckerRequest $request, array $userIdentifiers): Collection
{
return User::whereIn('email', $userIdentifiers)
->orWhereIn('id', $userIdentifiers)
->where('id', '!=', $request->maker_id)
->get();
}
public function getApproverByIdentifier(string $identifier): ?Model
{
return User::where('email', $identifier)
->orWhere('id', $identifier)
->first();
}
public function userExists(string $identifier): bool
{
return $this->getApproverByIdentifier($identifier) !== null;
}
public function validateUsersExist(array $userIdentifiers): array
{
return array_filter($userIdentifiers, fn($id) => !$this->userExists($id));
}
}
// Register in AppServiceProvider
$this->app->bind(ApproverResolver::class, CustomApproverResolver::class);
By default, all required roles are notified at once. Enable sequential mode to notify roles one at a time:
'notifications' => [
'sequential' => true,
],
In sequential mode:
MakerChecker::notifyNextApprovers($request) to manually trigger next notification// After partial approval, notify next approvers
MakerChecker::approve($request, $user, 'editor');
if ($request->isPartiallyApproved()) {
MakerChecker::notifyNextApprovers($request);
}
Override the default notifications with your own:
// config/maker-checker.php
'notifications' => [
'pending_notification' => App\Notifications\CustomPendingNotification::class,
'approved_notification' => App\Notifications\CustomApprovedNotification::class,
'rejected_notification' => App\Notifications\CustomRejectedNotification::class,
],
Your custom notification should accept a MakerCheckerRequest in its constructor:
use Illuminate\Notifications\Notification;
use Moffhub\MakerChecker\Models\MakerCheckerRequest;
class CustomPendingNotification extends Notification
{
public function __construct(
public MakerCheckerRequest $request,
public ?string $role = null
) {}
public function via($notifiable): array
{
return ['mail', 'database', 'slack']; // Add any channels
}
public function toMail($notifiable): MailMessage
{
return (new MailMessage)
->subject('Custom: Approval Needed')
->line("Please review: {$this->request->description}")
->action('Review', url("/approvals/{$this->request->code}"));
}
// Add toSlack(), toArray(), etc. as needed
}
Trigger notifications manually when needed:
// Notify all approvers about a pending request
MakerChecker::notifyApprovers($request);
// Notify with sequential mode (only first role)
MakerChecker::notifyApprovers($request, sequential: true);
// Notify next approvers after partial approval
MakerChecker::notifyNextApprovers($request);
// Access the notification service directly
$service = MakerChecker::notifications();
$service->notifyPendingApproval($request);
$service->notifyRequestApproved($request);
$service->notifyRequestRejected($request);
Register callbacks to execute at various points in the request lifecycle.
Define callbacks in the config file:
// config/maker-checker.php
'callbacks' => [
'on_initiated' => [
App\MakerChecker\Callbacks\LogNewRequest::class,
App\MakerChecker\Callbacks\SendSlackNotification::class,
],
'after_approval' => [
App\MakerChecker\Callbacks\UpdateAuditLog::class,
],
'after_rejection' => [
App\MakerChecker\Callbacks\NotifyManager::class,
],
'on_failure' => [
App\MakerChecker\Callbacks\AlertOps::class,
],
],
Callback classes should implement RequestCallback or have a handle method:
use Moffhub\MakerChecker\Contracts\RequestCallback;
use Moffhub\MakerChecker\Models\MakerCheckerRequest;
class LogNewRequest implements RequestCallback
{
public function handle(MakerCheckerRequest $request): void
{
Log::info('New approval request', [
'code' => $request->code,
'type' => $request->type->value,
'maker' => $request->maker_id,
]);
}
}
Register callbacks at runtime:
// In a service provider or bootstrap file
MakerChecker::callbacks()
->onInitiated(function (MakerCheckerRequest $request) {
// Request was just created
Log::info('Request initiated', ['code' => $request->code]);
})
->afterApproval(function (MakerCheckerRequest $request) {
// Request was fully approved and executed
Notification::send($request->maker, new RequestCompleted($request));
})
->afterRejection(function (MakerCheckerRequest $request) {
// Request was rejected
event(new RequestRejectedEvent($request));
})
->onFailure(function (MakerCheckerRequest $request) {
// Execution failed
Alert::critical("Request {$request->code} failed");
});
| Hook | When Executed |
|---|---|
on_initiated |
After a new request is created |
before_approval |
Before approval processing (per-request hooks only) |
after_approval |
After request is fully approved and executed |
before_rejection |
Before rejection processing (per-request hooks only) |
after_rejection |
After request is rejected |
on_failure |
When request execution fails |
All package API routes are rate-limited by default. Configure the limit in your config:
// config/maker-checker.php
'routes' => [
'rate_limit' => env('MAKER_CHECKER_RATE_LIMIT', 60), // requests per minute
],
Rate limiting is keyed by the authenticated user's ID or by IP address for unauthenticated requests. Set to 0 or null to disable rate limiting.
The rate limiter is registered under the name maker-checker, so you can reference it in your own routes if needed:
Route::middleware('throttle:maker-checker')->group(function () {
// Your custom maker-checker routes
});
The package automatically logs all approval actions (approve, reject, cancel, fail) with full context.
// config/maker-checker.php
'audit' => [
'enabled' => env('MAKER_CHECKER_AUDIT_ENABLED', true),
'driver' => env('MAKER_CHECKER_AUDIT_DRIVER', 'database'),
'table_name' => 'maker_checker_audit_logs',
'log_channel' => null, // Laravel log channel for 'log' driver
],
database (default): Writes audit entries to the maker_checker_audit_logs table. Best for querying and reporting.log: Writes audit entries to a Laravel log channel. Best for high-throughput systems where you want to offload to external log aggregation (ELK, Datadog, etc.).Each audit entry includes:
request_id - The maker-checker request IDactor_type / actor_id - Who performed the action (morph relationship)action - The action performed (approved, rejected, cancelled, partially_approved, failed)previous_status - The request status before the actionnew_status - The request status after the actionip_address - The IP address of the actormetadata - Additional context (e.g., exception messages for failures)When using the database config driver, you can define conditions that determine which configuration applies based on the request payload. This allows different approval requirements for different scenarios.
| Operator | Description | Example Value |
|---|---|---|
= |
Equal to | 50000 |
!= |
Not equal to | "draft" |
> |
Greater than | 10000 |
>= |
Greater than or equal | 5000 |
< |
Less than | 100 |
<= |
Less than or equal | 50 |
in |
Value in array | ["US", "EU", "UK"] |
not_in |
Value not in array | ["blocked", "suspended"] |
contains |
String contains | "urgent" |
starts_with |
String starts with | "VIP-" |
ends_with |
String ends with | "@company.com" |
is_null |
Value is null | (no value needed) |
is_not_null |
Value is not null | (no value needed) |
between |
Value between two numbers | [1000, 50000] |
regex |
Matches regex pattern | "^[A-Z]{3}\\d{4}$" |
Simple condition - high-value transfers require extra approval:
{
"mode": "all",
"rules": [
{"field": "amount", "operator": ">=", "value": 50000}
]
}
Multiple conditions (AND) - large international transfers:
{
"mode": "all",
"rules": [
{"field": "amount", "operator": ">=", "value": 10000},
{"field": "currency", "operator": "!=", "value": "USD"},
{"field": "destination_country", "operator": "not_in", "value": ["US", "CA"]}
]
}
Any condition (OR) - sensitive operations:
{
"mode": "any",
"rules": [
{"field": "amount", "operator": ">=", "value": 100000},
{"field": "category", "operator": "=", "value": "executive"},
{"field": "department", "operator": "in", "value": ["finance", "legal"]}
]
}
Nested groups - complex business rules:
{
"mode": "all",
"rules": [
{"field": "status", "operator": "=", "value": "active"}
],
"groups": [
{
"mode": "any",
"rules": [
{"field": "amount", "operator": ">=", "value": 50000},
{"field": "priority", "operator": "=", "value": "urgent"}
]
}
]
}
Using between for range checks:
{
"mode": "all",
"rules": [
{"field": "amount", "operator": "between", "value": [10000, 49999]},
{"field": "region", "operator": "in", "value": ["EMEA", "APAC"]}
]
}
Use the test endpoint to verify which config matches a payload:
POST /api/maker-checker/configs/test-conditions
Content-Type: application/json
{
"configurable_type": "App\\Models\\Transfer",
"action": "create",
"payload": {
"amount": 75000,
"currency": "EUR",
"destination_country": "DE"
}
}
The package supports multi-tenant setups where requests and configurations are scoped to teams/companies.
class User extends Authenticatable implements MakerCheckerUserContract
{
public function getMakerCheckerTeamId(): ?int
{
return $this->team_id;
}
// ... other contract methods
}
MakerChecker::request()
->toCreate(Invoice::class, $data, teamId: auth()->user()->team_id)
->madeBy(auth()->user())
->save();
Or use the builder methods:
MakerChecker::request()
->toCreate(Invoice::class, $data, requiredApprovals: [], teamId: $teamId)
->madeBy(auth()->user())
->save();
// config/maker-checker.php
'notifications' => [
'team_scoping' => true,
'team_attribute' => 'team_id', // attribute on user model
],
MakerCheckerConfig::create([
'configurable_type' => Invoice::class,
'action' => 'create',
'approvals' => ['manager' => 1],
'team_id' => 42, // Applies only to team 42
'is_active' => true,
]);
Requests are automatically filtered by team when using scopeVisibleTo:
// Users only see requests from their team
$requests = MakerCheckerRequest::visibleTo(auth()->user())->get();
Users with the view_any_permission bypass team filtering and see all requests.
The default ApproverResolver finds approvers by querying a role attribute on the user model. For more complex scenarios, implement your own resolver.
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Moffhub\MakerChecker\Contracts\ApproverResolver;
use Moffhub\MakerChecker\Models\MakerCheckerRequest;
class SpatieApproverResolver implements ApproverResolver
{
public function getApproversForRole(MakerCheckerRequest $request, string $role): Collection
{
return User::permission("maker-checker.approve.{$role}")
->when($request->team_id, fn($q) => $q->where('team_id', $request->team_id))
->where('id', '!=', $request->maker_id)
->get();
}
public function getAllApprovers(MakerCheckerRequest $request): Collection
{
$requiredApprovals = $request->required_approvals ?? [];
$roles = $requiredApprovals['roles'] ?? $requiredApprovals;
$users = $requiredApprovals['users'] ?? [];
$approvers = collect();
foreach (array_keys($roles) as $role) {
$approvers = $approvers->merge($this->getApproversForRole($request, $role));
}
if (!empty($users)) {
$approvers = $approvers->merge($this->getApproversByIdentifier($request, $users));
}
return $approvers->unique('id');
}
public function getApproversByIdentifier(MakerCheckerRequest $request, array $userIdentifiers): Collection
{
return User::where(function ($query) use ($userIdentifiers) {
$query->whereIn('email', $userIdentifiers)
->orWhereIn('id', $userIdentifiers);
})->get();
}
public function getApproverByIdentifier(string $identifier): ?Model
{
return User::where('email', $identifier)
->orWhere('id', $identifier)
->first();
}
public function userExists(string $identifier): bool
{
return $this->getApproverByIdentifier($identifier) !== null;
}
public function validateUsersExist(array $userIdentifiers): array
{
return array_filter($userIdentifiers, fn($id) => !$this->userExists($id));
}
}
Register it in your AppServiceProvider:
$this->app->bind(ApproverResolver::class, SpatieApproverResolver::class);
Create custom executable actions for complex operations that need approval:
use Moffhub\MakerChecker\Contracts\ExecutableRequest;
use Moffhub\MakerChecker\Models\MakerCheckerRequest;
class BulkUserImport extends ExecutableRequest
{
/**
* Execute the approved action.
*/
public function execute(MakerCheckerRequest $request): void
{
$payload = $request->payload;
foreach ($payload['users'] as $userData) {
User::create([
'name' => $userData['name'],
'email' => $userData['email'],
'role' => $userData['role'] ?? 'user',
'team_id' => $request->team_id,
]);
}
}
/**
* Fields used to determine request uniqueness.
* Prevents duplicate import requests with the same file hash.
*/
public function uniqueBy(): array
{
return ['file_hash'];
}
/**
* Runs before the request is approved.
* Use for pre-flight validation.
*/
public function beforeApproval(MakerCheckerRequest $request): void
{
$payload = $request->payload;
// Verify no duplicate emails in the import
$emails = array_column($payload['users'], 'email');
$existing = User::whereIn('email', $emails)->pluck('email');
if ($existing->isNotEmpty()) {
throw new \RuntimeException(
'Import contains existing emails: ' . $existing->implode(', ')
);
}
}
/**
* Runs after successful approval and execution.
*/
public function afterApproval(MakerCheckerRequest $request): void
{
$count = count($request->payload['users'] ?? []);
Log::info("Bulk import completed: {$count} users imported", [
'request_id' => $request->id,
'team_id' => $request->team_id,
]);
}
/**
* Runs before the request is rejected.
*/
public function beforeRejection(MakerCheckerRequest $request): void
{
// Optional: cleanup temporary files
}
/**
* Runs after the request is rejected.
*/
public function afterRejection(MakerCheckerRequest $request): void
{
Notification::send($request->maker, new ImportRejectedNotification($request));
}
/**
* Runs when execution fails.
*/
public function onFailure(MakerCheckerRequest $request): void
{
Log::error('Bulk import failed', [
'request_id' => $request->id,
'exception' => $request->exception,
]);
}
}
Use it:
MakerChecker::request()
->toExecute(BulkUserImport::class, [
'file_hash' => md5_file($uploadedFile->path()),
'users' => $parsedUsers,
])
->withApprovals(['hr_manager' => 1, 'admin' => 1])
->madeBy(auth()->user())
->save();
composer test
Run the full check suite:
composer check-code # Runs lint, phpstan, and tests
| Option | Default | Description |
|---|---|---|
ensure_requests_are_unique |
true |
Prevent duplicate pending requests |
request_expiration_in_minutes |
null |
Auto-expire after N minutes |
default_approval_count |
1 |
Default approvals when not specified |
table_name |
maker_checker_requests |
Requests table name |
config_table_name |
maker_checker_configs |
Configs table name |
delete_on_completion |
true |
Delete requests after execution |
soft_delete_on_completion |
false |
Soft delete instead |
view_any_permission |
maker-checker.view-any |
Permission to view all requests |
config_driver |
file |
file or database |
cache_config |
true |
Cache database configs |
config_cache_ttl |
3600 |
Cache TTL in seconds |
routes.rate_limit |
60 |
Rate limit per minute (0 to disable) |
notifications.enabled |
false |
Enable automatic notifications |
notifications.channels |
['mail', 'database'] |
Notification delivery channels |
notifications.notify_maker |
true |
Notify maker on approval/rejection |
notifications.sequential |
false |
Notify roles one at a time |
notifications.role_attribute |
role |
User model attribute for role |
audit.enabled |
true |
Enable audit logging |
audit.driver |
database |
Audit storage: database or log |
audit.table_name |
maker_checker_audit_logs |
Audit log table name |
audit.log_channel |
null |
Laravel log channel for log driver |
This error occurs when using the convenience methods (MakerChecker::create(), MakerChecker::approve()) without an authenticated user. Solutions:
->madeBy($user) to explicitly pass a userBy default, the same user cannot both create and approve a request. To allow this for specific users (e.g., admins in development):
// .env
MAKER_CHECKER_WHITELISTED_EMAILS=admin@example.com,super@example.com
This happens when:
request_model config points to a class that doesn't extend MakerCheckerRequestFix: Ensure your custom model extends MakerCheckerRequest:
class CustomRequest extends \Moffhub\MakerChecker\Models\MakerCheckerRequest
{
// Your customizations
}
When ensure_requests_are_unique is true, creating a request with the same payload as an existing pending request throws a DuplicateRequestException. Solutions:
uniqueBy() on the builder to specify which fields determine uniquenessensure_requests_are_unique to false if duplicates are acceptable'notifications.enabled' => trueuser_model is set or auth.providers.users.model is configuredNotifiable traitApproverResolver returns users for the required rolesThe package validates configuration when the application boots (except during tests). Common issues:
default_approval_count must be >= 1config_driver must be file or databaserequest_model must be a class extending MakerCheckerRequestwhitelisted_models.maker and whitelisted_models.checker must be arraysWhen using the database config driver:
'config_driver' => 'database'php artisan migrateis_active: truephp artisan cache:clearteam_id matches (team-specific configs only apply to that team)Adjust the rate limit per minute:
// config/maker-checker.php
'routes' => [
'rate_limit' => 120, // Increase to 120 per minute
],
Or disable rate limiting entirely:
'routes' => [
'rate_limit' => 0, // Disabled
],
No built-in queue support for fulfillment: When a request is approved, the underlying operation (create/update/delete/execute) runs synchronously within the approval request. For long-running operations, implement your own queue dispatch inside an ExecutableRequest.
JSON payload comparison: Duplicate request checking uses JSON field comparisons (payload->field), which may behave differently across database engines (MySQL vs PostgreSQL vs SQLite).
Single approval per user: A user can only approve a request once. They cannot approve under multiple roles for the same request.
No partial rollback: If fulfillment fails after approval, the request is marked as failed but any partial side effects from hooks (beforeApproval) are not rolled back.
Config driver is global: You cannot use different config drivers for different models. The config_driver setting applies to all models.
Morph map dependency: The package uses polymorphic relationships for maker/checker/subject. If you change your morph map after requests are created, existing requests may break.
No built-in approval deadlines: While requests can expire, there is no built-in deadline per approval step in a multi-role chain. All roles have the same expiration window.
For high-traffic production deployments, see docs/PERFORMANCE.md for:
MIT License. See LICENSE for details.
How can I help you explore Laravel packages today?