Browse documentationOpen

byrcsc/laravel-approval · 1.x

Approvers and resolvers.

Assign exact approver identities, and use approver resolvers to turn roles, teams, and reporting lines into concrete assignments at submission.

An approver entry names one exact actor identity. The model named on the entry is the only identity that may decide it.

$stage->approvers()->create([
    'approver_type' => $financeUser->getMorphClass(),
    'approver_id' => $financeUser->getKey(),
]);

A Team, Role, Position, or other container model is not expanded into its members. Assigning a team assigns the team record itself, and only something acting as that team can approve. To assign the members, use an approver resolver.

Approver columns are polymorphic, so an assignment can name any model. The package never assumes your users are App\Models\User.

Approver resolvers

A resolver turns an organizational rule into concrete actor models, at the moment a request is submitted:

use ByRcsc\LaravelApproval\Contracts\ApproverResolver;
use ByRcsc\LaravelApproval\Data\StageSnapshot;
use Illuminate\Database\Eloquent\Model;

final class FinanceTeamMembers implements ApproverResolver
{
    public function resolve(
        StageSnapshot $stage,
        Model $approvable,
        Model $requester,
        array $config,
    ): iterable {
        return Team::findOrFail($config['team'])->members;
    }
}

Register it on the stage instead of a concrete approver:

$stage->approvers()->create([
    'resolver' => FinanceTeamMembers::class,
    'resolver_config' => ['team' => $financeTeam->getKey()],
]);

An approver entry names either a model or a resolver, never both. The engine enforces that, since no portable database constraint expresses it.

Rules resolvers must follow

  • Return persisted models. An assignment records an exact actor identity, so an unsaved model raises InvalidWorkflowException.
  • Be free of side effects. Every stage is planned before the request is written, so a resolver can run and the submission still be abandoned because a later stage turns out to be invalid.
  • Tolerate an empty answer. Returning nothing is allowed; the engine decides whether that is fatal. It is fatal for a stage whose condition passed, because a stage with no approvers can never complete.

Resolvers are resolved from the container, so constructor injection works.

Resolvers run exactly once

A resolver is a question asked at a point in time, not a live query. Its answer is written into the stage's assignments and never consulted again, so an approver list that changes tomorrow does not rewrite what was asked today.

Getting a stale assignment fixed is deliberate work: delegate it, reassign it, or resync the request.

Duplicates collapse

Two entries that name the same record are one assignment, however differently they were reached. The same person returned by two resolvers on one stage produces a single assignment — counting them twice would quietly halve the approvals the stage really has.

Shipped resolvers

StaticUsersResolver

A list of primary keys, useful when several stages share one list and you would rather edit it in one place:

use ByRcsc\LaravelApproval\Resolvers\StaticUsersResolver;

$stage->approvers()->create([
    'resolver' => StaticUsersResolver::class,
    'resolver_config' => ['ids' => [4, 11], 'model' => Team::class],
]);

model is optional and defaults to approval.user_model.

CallbackResolver

Delegates to a class the application already has, so an existing ManagerLookup::forEmployee() becomes an approver rule without being rewritten around this package's interface:

use ByRcsc\LaravelApproval\Resolvers\CallbackResolver;

$stage->approvers()->create([
    'resolver' => CallbackResolver::class,
    'resolver_config' => ['callback' => 'App\Org\ManagerLookup@forEmployee'],
]);

The class comes out of the container, so its own dependencies are injected, and the method receives the same four arguments an ApproverResolver does. Omit @method to call __invoke. Anything the method returns that is not an Eloquent model is dropped.

Reach for this when the logic already exists somewhere. When it does not, write the resolver: an implementation of the interface is about fifteen lines and says what it is in its own name.

Assignment sources

Every assignment records how its holder came to hold it:

SourceMeaning
assignedCopied from a concrete approver entry at submission
resolvedProduced by a resolver at submission
delegatedAn approver handed the assignment on themselves
reassignedAn administrator rewrote who the assignment belongs to
escalatedAn overdue stage's escalation policy added the approver
resyncedAn administrative resync added the approver

assigned and resolved are what the submit-time snapshot produced; the rest record a deliberate intervention on a request already in flight.

Self-approval

A requester may not approve their own request unless the policy allows it. Enable it globally:

APPROVAL_ALLOW_SELF_APPROVAL=true

Or per workflow, which takes precedence:

$workflow->update(['settings' => ['allow_self_approval' => true]]);

A denied attempt surfaces as Ineligibility::SelfApproval from an eligibility check, or SelfApprovalException from a decision.

Stranded stages

An approval rule counts assignments. If the records those assignments point at are deleted, a stage can end up needing more approvals than there are living holders — nobody can finish it, and nothing else in the engine will ever say so.

approval:doctor finds them:

php artisan approval:doctor

Delegation and reassignment are judged against whether they made a stage's deficit worse, not against perfection, so an already-stranded stage stays open to the interventions that are trying to repair it.