›
›
›
  1. docs
  2. ›
  3. byrcsc/laravel-approval
1.x
Browse documentationOpenClose

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Workflows and stages
  • Choosing the workflow
  • Approvers and resolvers
  • Requests and state
  • Conditional stages

Approval flow

  • Submitting for approval
  • Recording decisions
  • Attribute drafts
  • Returns and resubmission
  • Bulk decisions

Assignments and deadlines

  • Delegation and reassignment
  • SLAs and escalation

Reading and authorization

  • Eligibility and authorization
  • Queries and timelines
  • Events and listeners
  • Notifications
  • Notification content

Operations

  • Workflow definitions
  • Audit trail and evidence
  • Console commands
  • Testing
  • Troubleshooting

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Workflows and stages
  • Choosing the workflow
  • Approvers and resolvers
  • Requests and state
  • Conditional stages

Approval flow

  • Submitting for approval
  • Recording decisions
  • Attribute drafts
  • Returns and resubmission
  • Bulk decisions

Assignments and deadlines

  • Delegation and reassignment
  • SLAs and escalation

Reading and authorization

  • Eligibility and authorization
  • Queries and timelines
  • Events and listeners
  • Notifications
  • Notification content

Operations

  • Workflow definitions
  • Audit trail and evidence
  • Console commands
  • Testing
  • Troubleshooting

byrcsc/laravel-approval · 1.x

Testing.

Test approval mutations with a fake and persisted states with factories.

Approval::fake() records every public mutation instead of performing it, and provides an assertion for each:

$fake = Approval::fake();

$order->submitForApproval('purchase-order', ['amount' => 10_000]);

$fake->assertSubmitted(
    $order,
    fn ($recorded) => $recorded->metadata['new_attributes'] === ['amount' => 10_000],
);

$fake->restore();

Nothing is written to the approval tables while the fake is active. Each faked command returns an unsaved model, or an empty result, of the shape the real operation returns, so code under test that reads the returned request keeps working.

What is faked, and what is not

Mutations are faked. Submission, decisions, bulk decisions, withdrawal, cancellation, delegation, reassignment, resynchronization, conflict resolution, resubmission, attachment, guard bypass, definition sync, and overdue processing.

Queries are not. awaiting(), pendingFor(), requestsFor(), overdue(), workflow(), and verifyTrail() run against the database as usual, so a test can seed real requests and still fake the decisions taken on them.

Resolution is not faked either. A faked submit() with no requester resolves one the way the real manager does, through approvalRequester() and then the authenticated user, so a test that forgets to authenticate fails the same way production would.

Assertions

AssertionCovers
assertSubmitted($approvable, $callback?)Submission
assertNotSubmitted($approvable)none
assertSubmittedTimes($count, $approvable?)none
assertApproved($request, $callback?)An approval decision
assertRejected($request, $callback?)A rejection
assertReturnedForRevision($request, $callback?)A return for revision
assertNotApproved($request)none
assertApprovedTimes($count, $request?)none
assertRejectedTimes($count, $request?)none
assertApprovedMany($callback?)A bulk approval
assertRejectedMany($callback?)A bulk rejection
assertWithdrawn($request, $callback?)Withdrawal
assertCancelled($request, $callback?)Administrative cancellation
assertDelegated($request, $callback?)Delegation
assertReassigned($request, $callback?)Reassignment
assertResynced($request, $callback?)Resynchronization
assertConflictResolved($request, $callback?)Conflict resolution
assertResubmitted($request, $callback?)Revise and resubmit
assertAttached($request, $callback?)Attachment
assertGuardBypassed($approvable, $callback?)A guard-mode bypass
assertOverdueProcessed($callback?)The overdue sweep
assertDefinitionsSynced($callback?)Definition sync
assertNothingRecorded()All of them

assertNothingRecorded() names the interaction types that were recorded when it fails, which is usually enough to see what happened without a debugger.

Inspecting an interaction

The optional callback receives an ApprovalInteraction and returns a boolean:

$fake->assertDelegated($request, fn ($recorded) =>
    $recorded->recipient->is($deputy)
    && $recorded->comment === 'On leave this week');
PropertyMeaning
typeAn InteractionType
targetThe request, record, or list of requests it acted on
actorWho performed it
workflowThe workflow argument, for submission and resubmission
verdictThe Verdict, for decisions
commentThe comment, trimmed
metadataOperation-specific input
recipientThe approver an assignment moved to

target is null for the operations that act on no single subject, overdue processing and definition sync.

Read them all with $fake->interactions() when an assertion helper does not fit.

Restoring

$fake->restore();

Puts the real manager back in the container and on the facade. Calling fake() again on an active fake clears its recorded interactions instead of nesting.

Testing without the fake

Integration tests that want the real engine need only a database. Everything the package does is local, no HTTP, no queue, no external service:

it('applies the draft after the final approval', function () {
    $order = PurchaseOrder::factory()->create(['amount' => 4_000]);
    $request = $order->submitForApproval('purchase-order', ['amount' => 10_000]);

    Approval::approve($request, $financeUser);

    expect($order->fresh()->amount)->toBe(10_000)
        ->and($request->fresh()->state)->toBe(RequestState::Approved);
});

Factories

Every model ships a factory under ByRcsc\LaravelApproval\Database\Factories:

use ByRcsc\LaravelApproval\Models\ApprovalWorkflow;

$workflow = ApprovalWorkflow::factory()
    ->has(ApprovalWorkflowStage::factory()->count(2), 'stages')
    ->create(['slug' => 'purchase-order']);

Useful for building an odd request state directly, a conflicted request, a stranded stage, that would take several steps to reach through the engine.

Asserting events

Approval events are ordinary Laravel events:

Event::fake([ApprovalCompleted::class]);

Approval::approve($request, $financeUser);

Event::assertDispatched(ApprovalCompleted::class);

Note that faking events stops the notification listener too, since it is registered against those events.

Asserting notifications

config(['approval.notifications.enabled' => true]);

Notification::fake();

$order->submitForApproval('purchase-order');

Notification::assertSentTo($financeUser, ApprovalRequestedNotification::class);

Delivery is deferred with DB::afterCommit(). Outside a transaction the callback runs immediately, so a test that does not wrap the call in one sees the notification without extra work.

Contributing to the package

The package's own suite needs no external database, .env, or service:

composer install
composer test
composer analyse
vendor/bin/pint --test

What to read next

  • Events and listeners to choose between fake interactions and real event assertions.
  • Eligibility and authorization to test refusal reasons and policies.
  • Troubleshooting for states that require persisted fixtures.
PreviousConsole commandsNextTroubleshooting
View source

On this page

  1. What is faked, and what is not
  2. Assertions
  3. Inspecting an interaction
  4. Restoring
  5. Testing without the fake
  6. Factories
  7. Asserting events
  8. Asserting notifications
  9. Contributing to the package
  10. What to read next