NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Testing helpers for your own app live in RoundlyConsulting\Approvals\Testing. They pull in no runtime dependency — Pest is only touched when you call the registrar.

Approvals::fake()

Approvals::fake() swaps in ApprovalsFake, a subtype of ApprovalsManager, for the facade and for injected managers, and returns it. It is a recording fake that still performs: operations run against your database as usual, and the fake records each one — whether it came through the facade, an injected manager, a model trait such as $user->approve($post) or the test helpers below:

use RoundlyConsulting\Approvals\Facades\Approvals;

it('approves the invoice', function (): void {
    $fake = Approvals::fake();

    $this->actingAs($cfo)->post("/invoices/{$invoice->id}/approve");

    $fake->assertApproved($invoice, by: $cfo);   // or Approvals::assertApproved(...)
    $fake->assertNothingRejected();
});

Every assert is also callable statically on the facade — Approvals::assertApproved($invoice, $cfo). An operation that throws is not recorded.

Every assertion

AssertPasses whenNegative
assertApproved($approvable, ?$by)an approval was recorded (by $by)assertNothingApproved()
assertRejected($approvable, ?$by)a rejection was recordedassertNothingRejected()
assertAsked($approvable, ?$actor)a pending decision was asked for with ask()assertNothingAsked()
assertCancelled($approvable, ?$by)a cancel ranassertNothingCancelled()
assertToggled($approvable, ?$by)a toggle ranassertNothingToggled()
assertOpened($subject, ?$workflow)a request was opened for the subject (from the preset)assertNothingOpened()
assertDelegated($delegator, ?$to)a delegation was grantedassertNothingDelegated()
assertRevoked($delegator, ?$delegate)a revoke ran — a revoke-all records no delegateassertNothingRevoked()
assertExpired(?$count, ?$subjectType)an expiry sweep ran (and lapsed $count decisions and requests in total); with $subjectType only sweeps scoped to that type countassertNothingExpired()
$fake = Approvals::fake();

// Decisions — the actor argument is optional
$fake->assertApproved($invoice, by: $cfo);
$fake->assertRejected($invoice, by: $auditor);
$fake->assertAsked($contract, $legal);
$fake->assertCancelled($contract, by: $legal);
$fake->assertToggled($post, by: $user);

// Requests — optionally from a named preset
$fake->assertOpened($release);
$fake->assertOpened($budget, 'payout');

// Delegation
$fake->assertDelegated($manager, to: $assistant);
$fake->assertRevoked($manager, $assistant);

// Expiry sweeps — a sweep ran, and lapsed 3 decisions and requests in total
$fake->assertExpired(3);
$fake->assertExpired(subjectType: Invoice::class);   // only sweeps scoped to invoices count

// Every assert has a negative twin
$fake->assertNothingApproved();
$fake->assertNothingRejected();
$fake->assertNothingAsked();
$fake->assertNothingCancelled();
$fake->assertNothingToggled();
$fake->assertNothingOpened();
$fake->assertNothingDelegated();
$fake->assertNothingRevoked();
$fake->assertNothingExpired();   // nothing lapsed

The raw log

recorded(?ApprovalOperation) returns the RecordedApprovalOperation list in call order — optionally one kind of operation: Approve, Reject, Ask, Cancel, Toggle, Open, Delegate, Revoke or Expire. Each entry carries the operation, its context models and the result, for custom assertions:

use RoundlyConsulting\Approvals\Enums\ApprovalOperation;

$opened = $fake->recorded(ApprovalOperation::Open);   // list<RecordedApprovalOperation>

$opened[0]->operation;                  // ApprovalOperation::Open
$opened[0]->context['workflow'];        // 'payout' or null
$opened[0]->model('subject');           // ?Model
$opened[0]->involves('subject', $budget);   // bool
$opened[0]->result;                     // the ApprovalRequest the call returned

Pest expectations

Register them once in tests/Pest.php:

// tests/Pest.php
use RoundlyConsulting\Approvals\Testing\ApprovalExpectations;

ApprovalExpectations::register();
  • toBeApproved() — the model’s status is Approved.
  • toBePendingApproval() — the status is Pending.
  • toBeRejected() — the status is Rejected.

The status comes from currentApprovalStatus() when the model uses RequiresApproval; otherwise it is derived from the decisions still in force against the model — any rejection wins, then any approval, else pending.

Acting as an approver

use App\Models\Release;
use App\Models\User;
use RoundlyConsulting\Approvals\Testing\InteractsWithApprovals;

uses(InteractsWithApprovals::class);

it('approves a release once both reviewers sign off', function (): void {
    $release = Release::factory()->create();
    $lead = User::factory()->create();
    $qa = User::factory()->create();

    $release->requestApproval([$lead, $qa]);

    $this->actingAsApprover($lead)->approveAs($release);
    expect($release)->toBePendingApproval();

    $this->approveAs($release, 'Tests pass', $qa);
    expect($release)->toBeApproved();
});

it('rejects with a reason', function (): void {
    $release = Release::factory()->create();
    $reviewer = User::factory()->create();
    $release->requestApproval([$reviewer]);

    $this->rejectAs($release, 'needs work', $reviewer);

    expect($release)->toBeRejected();
});

approveAs() and rejectAs() take the approvable, an optional reason and an optional actor; with no remembered or passed actor they throw a RuntimeException. They go through ApprovalsManager, so the gate, named approvers, delegation and request resolution all apply — and Approvals::fake() records them.

Factories

use RoundlyConsulting\Approvals\Models\Approval;
use RoundlyConsulting\Approvals\Models\ApprovalDelegation;
use RoundlyConsulting\Approvals\Models\ApprovalRequest;

Approval::factory()->pending()->forActor($user)->forApprovable($post)
    ->create(['expires_at' => now()->subHour()]);

Approval::factory()->approved()->weight(3)->delegated($assistant)->create();

ApprovalRequest::factory()->forSubject($release)->weighted(3)->create();

ApprovalDelegation::factory()->forDelegator($manager)->toDelegate($assistant)->create();
FactoryStates
ApprovalFactorypending(), approved(), rejected(), cancelled(), expired(), weight($n), delegated($decidedBy), forStage($stage), forActor($actor), forApprovable($approvable)
ApprovalRequestFactoryunanimous(), quorum($n), any(), weighted($threshold), staged(), forSubject($subject)
ApprovalRequestStageFactoryforRequest($request), atPosition($n), quorum($n), cleared(), pending()
ApprovalDelegationFactoryforDelegator($delegator), toDelegate($delegate), expired(), revoked()

The package’s own suite

To run the package’s test suite from a checkout:

composer test

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.