moffhub/maker-checker

A laravel package to help manage with maker checker tight requirement

Installs: 8 443

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Watchers: 1

Forks: 0

Open Issues: 0

Type:project

pkg:composer/moffhub/maker-checker

v0.1.0 2026-01-29 20:40 UTC

This package is auto-updated.

Last update: 2026-01-29 20:42:42 UTC


README

A Laravel package for implementing the Maker-Checker (Four Eyes) approval workflow pattern. This pattern ensures that critical operations require approval from one or more reviewers before execution.

Features

  • Multi-role approvals - Require approvals from specific roles (e.g., 2 admins + 1 manager)
  • CRUD operations - Built-in support for Create, Update, Delete, and custom Execute operations
  • Flexible configuration - Configure via file, database, or model interfaces
  • Multi-tenancy support - Team/company scoping for requests
  • API ready - RESTful API endpoints for managing requests
  • Hooks & callbacks - Execute custom logic before/after approval or rejection
  • Request expiration - Auto-expire pending requests after a configurable time
  • Audit trail - Track who made and approved each request

Installation

Install 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

Quick Start

Option A: Automatic Model Event Interception (Recommended)

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']);
});

Option B: Convenience Methods (Simple API)

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();

Option C: Request Builder (Full Control)

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();

1. Implement the User Contract

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;
    }
}

2. Create a Pending Request

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();

3. Approve or Reject

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

Request Types

Create

MakerChecker::request()
    ->toCreate(Post::class, ['title' => 'New Post', 'content' => '...'])
    ->withApprovals(['admin' => 1])
    ->madeBy(auth()->user())
    ->save();

Update

MakerChecker::request()
    ->toUpdate($post, ['title' => 'Updated Title'])
    ->madeBy(auth()->user())
    ->save();

Delete

MakerChecker::request()
    ->toDelete($post)
    ->withApprovals(['admin' => 2]) // Require 2 admin approvals
    ->madeBy(auth()->user())
    ->save();

Execute (Custom Actions)

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();

Multi-Role Approvals

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

User-Specific Approvals

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.

Requiring Specific Users

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();

Combining Roles and Users

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();

User Validation

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();

Checking Pending Users

$request->requiresUserApprovals();   // true if users are required
$request->getPendingUsers();         // ['cfo@company.com', ...] remaining

Approval Flow

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

Configuration

Model-Based Configuration

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",
        };
    }
}

File-Based Configuration

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],
],

Database-Driven Configuration

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,
]);

Configuration API

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"]
    }
}

UI Mockup

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.

API Endpoints

The package provides RESTful API endpoints (prefix configurable):

Requests

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

Query Parameters

GET /api/maker-checker/requests?status=pending&type=create&team_id=1

Configs (database driver)

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

Route Configuration

// 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

Request Statuses

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

Hooks

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();

Automatic Model Interception

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.

Basic Usage

use Moffhub\MakerChecker\Traits\RequiresApproval;

class Transaction extends Model
{
    use RequiresApproval;
}

Configuration via Properties

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],
    ];
}

Bypassing Approval

// 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
});

Handling Interception

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();

Exception Mode (Optional)

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);
}

Checking Pending Approvals

$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);

Setting the Maker

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);

Visibility Scoping

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

Expiring 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();

Notifications

The package can automatically notify approvers when a new request is pending, and notify makers when their request is approved or rejected.

Enabling Notifications

// 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
],

Finding Approvers by 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);

Sequential Notifications

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:

  1. First role is notified when request is created
  2. After that role approves, next role is notified
  3. Use 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);
}

Custom Notification Classes

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
}

Manual Notifications

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);

Lifecycle Callbacks

Register callbacks to execute at various points in the request lifecycle.

Config-Based Callbacks

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,
        ]);
    }
}

Programmatic Callbacks

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");
    });

Available Hooks

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

Testing

composer test

Run the full check suite:

composer check-code  # Runs lint, phpstan, and tests

Configuration Reference

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
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

License

MIT License. See LICENSE for details.