DEV Community

Tuğrul Yıldırım
Tuğrul Yıldırım

Posted on

Building an RFQ Approval Matrix in Laravel Without Hardcoded Thresholds

Every procurement tool starts with a simple rule: "purchases over 10,000 need a manager's approval." Six months later the rule looks like this: over 10,000 needs the department head, over 50,000 also needs finance, IT hardware always needs the IT lead, and anything in a foreign currency is compared after conversion. If those rules live in if statements inside a controller, every policy change becomes a deploy.

This post walks through how I model an approval matrix for RFQs (requests for quotation) in Laravel so the rules live in data, the workflow lives in one service, and two people clicking "Approve" at the same second cannot corrupt the state.

The problem

An RFQ in a typical business app goes through a few stages: a requester drafts it, suppliers send quotes, the requester picks a winning quote, and then the selection has to be approved before it turns into a purchase order.

The approval part is where things usually break:

  • Thresholds are hardcoded. Changing 10,000 to 15,000 needs a developer.
  • Steps are implicit. Nobody can answer "who still needs to approve RFQ #482?" without reading code.
  • Concurrency is ignored. Two approvers act at once, both read "1 step left", and the RFQ is marked approved twice or skips a step.
  • Re-quotes are forgotten. A supplier revises the price upward after approval, and the old approval silently still counts.

Let's fix all four.

Step 1: Store rules as rows, not code

An approval rule says: for RFQs in this amount range (and optionally this category), this role must approve, at this step.

// database/migrations/2026_10_23_000001_create_approval_rules_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('approval_rules', function (Blueprint $table) {
            $table->id();
            $table->string('category')->nullable();          // null = any category
            $table->unsignedBigInteger('min_amount');         // minor units, base currency
            $table->unsignedBigInteger('max_amount')->nullable(); // null = no upper limit
            $table->string('approver_role');
            $table->unsignedTinyInteger('step');
            $table->boolean('active')->default(true);
            $table->timestamps();
        });

        Schema::create('rfq_approvals', function (Blueprint $table) {
            $table->id();
            $table->foreignId('rfq_id')->constrained()->cascadeOnDelete();
            $table->unsignedTinyInteger('step');
            $table->string('approver_role');
            $table->foreignId('approved_by')->nullable()->constrained('users');
            $table->timestamp('approved_at')->nullable();
            $table->unsignedBigInteger('approved_amount')->nullable();
            $table->timestamps();

            $table->unique(['rfq_id', 'step', 'approver_role']);
        });
    }
};
Enter fullscreen mode Exit fullscreen mode

Two decisions matter here:

  1. Amounts are integers in minor units (cents, kuruş). Floats and money do not mix; 0.1 + 0.2 is not 0.3.
  2. Amounts are in a base currency. When an RFQ is submitted, convert the selected quote once and store both the converted amount and the rate you used. Approvals are evaluated against that snapshot, not against today's exchange rate.

Step 2: Make the status explicit with a backed enum

PHP 8.1+ backed enums plus Eloquent's enum casting give you a status that cannot hold a typo.

// app/Enums/RfqStatus.php
namespace App\Enums;

enum RfqStatus: string
{
    case Draft = 'draft';
    case Quoting = 'quoting';
    case PendingApproval = 'pending_approval';
    case Approved = 'approved';
    case Rejected = 'rejected';

    /** @return array<int, self> */
    public function allowedTransitions(): array
    {
        return match ($this) {
            self::Draft => [self::Quoting],
            self::Quoting => [self::PendingApproval],
            self::PendingApproval => [self::Approved, self::Rejected, self::Quoting],
            self::Approved, self::Rejected => [],
        };
    }

    public function canTransitionTo(self $next): bool
    {
        return in_array($next, $this->allowedTransitions(), true);
    }
}
Enter fullscreen mode Exit fullscreen mode
// app/Models/Rfq.php (excerpt)
use App\Enums\RfqStatus;

protected function casts(): array
{
    return [
        'status' => RfqStatus::class,
    ];
}
Enter fullscreen mode Exit fullscreen mode

Notice PendingApproval → Quoting. That is the re-quote path, and it matters later.

Step 3: Resolve the required steps in one place

The matrix resolver takes an amount and a category and returns the steps that apply. Keeping it pure (no database writes, no HTTP) makes it trivial to test.

// app/Services/ApprovalMatrix.php
namespace App\Services;

use App\Models\ApprovalRule;
use Illuminate\Support\Collection;

class ApprovalMatrix
{
    /**
     * @return Collection<int, ApprovalRule>
     */
    public function requiredSteps(int $amount, ?string $category): Collection
    {
        return ApprovalRule::query()
            ->where('active', true)
            ->where('min_amount', '<=', $amount)
            ->where(fn ($q) => $q->whereNull('max_amount')->orWhere('max_amount', '>=', $amount))
            ->where(fn ($q) => $q->whereNull('category')->orWhere('category', $category))
            ->orderBy('step')
            ->get()
            ->unique(fn (ApprovalRule $rule) => $rule->step.'|'.$rule->approver_role)
            ->values();
    }
}
Enter fullscreen mode Exit fullscreen mode

When the requester submits the selected quote, you materialize those rules into rfq_approvals rows. From that moment on, the RFQ carries its own checklist. If someone edits the rules table tomorrow, RFQs already in flight keep the rules they were submitted under, which is what auditors expect.

// app/Actions/SubmitRfqForApproval.php
namespace App\Actions;

use App\Enums\RfqStatus;
use App\Models\Rfq;
use App\Services\ApprovalMatrix;
use DomainException;
use Illuminate\Support\Facades\DB;

class SubmitRfqForApproval
{
    public function __construct(private ApprovalMatrix $matrix) {}

    public function handle(Rfq $rfq): void
    {
        DB::transaction(function () use ($rfq) {
            $rfq = Rfq::whereKey($rfq->getKey())->lockForUpdate()->firstOrFail();

            if (! $rfq->status->canTransitionTo(RfqStatus::PendingApproval)) {
                throw new DomainException("RFQ {$rfq->id} cannot be submitted from {$rfq->status->value}.");
            }

            $steps = $this->matrix->requiredSteps($rfq->base_amount, $rfq->category);

            $rfq->approvals()->delete(); // clean slate on re-submission

            foreach ($steps as $rule) {
                $rfq->approvals()->create([
                    'step' => $rule->step,
                    'approver_role' => $rule->approver_role,
                ]);
            }

            $rfq->status = $steps->isEmpty() ? RfqStatus::Approved : RfqStatus::PendingApproval;
            $rfq->save();
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Approve without race conditions

This is the part most implementations get wrong. The approval action must:

  • lock the RFQ row so two approvals are serialized,
  • only allow the lowest open step to be approved,
  • block the requester from approving their own RFQ,
  • record the amount that was approved,
  • flip the RFQ to Approved only when no open steps remain.
// app/Actions/ApproveRfqStep.php
namespace App\Actions;

use App\Enums\RfqStatus;
use App\Models\Rfq;
use App\Models\User;
use DomainException;
use Illuminate\Support\Facades\DB;

class ApproveRfqStep
{
    public function handle(Rfq $rfq, User $approver): void
    {
        DB::transaction(function () use ($rfq, $approver) {
            $rfq = Rfq::whereKey($rfq->getKey())->lockForUpdate()->firstOrFail();

            if ($rfq->status !== RfqStatus::PendingApproval) {
                throw new DomainException('RFQ is not waiting for approval.');
            }

            if ($rfq->requested_by === $approver->id) {
                throw new DomainException('Requesters cannot approve their own RFQ.');
            }

            $open = $rfq->approvals()->whereNull('approved_at')->orderBy('step')->get();
            $currentStep = $open->min('step');

            $slot = $open
                ->where('step', $currentStep)
                ->first(fn ($a) => $approver->hasRole($a->approver_role));

            if ($slot === null) {
                throw new DomainException('You have no open approval on the current step.');
            }

            $slot->update([
                'approved_by' => $approver->id,
                'approved_at' => now(),
                'approved_amount' => $rfq->base_amount,
            ]);

            if ($open->count() === 1) {
                $rfq->status = RfqStatus::Approved;
                $rfq->save();
            }
        }, attempts: 3);
    }
}
Enter fullscreen mode Exit fullscreen mode

lockForUpdate() inside DB::transaction() is the documented Laravel approach for pessimistic locking: the second approver's query waits until the first transaction commits, then reads the updated state. The attempts: 3 argument tells Laravel to retry the closure if the database reports a deadlock.

hasRole() here stands for whatever your authorization layer provides (a package, a roles relation, or a Gate). Keep the check inside the transaction so it is evaluated against the locked state.

Step 5: Invalidate approvals on re-quote

If the winning supplier revises their price, the approvals are no longer valid for the new number. The simplest safe rule: any change to the selected quote's amount sends the RFQ back to Quoting, and re-submission rebuilds the checklist (that is why SubmitRfqForApproval deletes old approval rows).

// app/Observers/QuoteObserver.php (excerpt)
public function updated(Quote $quote): void
{
    $rfq = $quote->rfq;

    if ($quote->wasChanged('amount')) {
        if ($rfq->selected_quote_id === $quote->id
            && $rfq->status === RfqStatus::PendingApproval) {
            $rfq->status = RfqStatus::Quoting;
            $rfq->save();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Because approved_amount is stored on each approval row, you also get a cheap audit check: an approved RFQ whose base_amount differs from any approved_amount is a red flag worth alerting on.

Common pitfalls

  • Overlapping ranges. If one rule covers 0–10,000 and another 10,000–50,000 for the same role, an RFQ of exactly 10,000 matches both. Pick one convention (for example, max_amount is inclusive and the next range starts at max_amount + 1) and validate rules on save so ranges for the same role and category never overlap.
  • Using lockForUpdate() outside a transaction. The lock is released as soon as the statement finishes, so it protects nothing. Always wrap it.
  • Evaluating the matrix at approval time. Rules change. Materialize them at submission and approve against the snapshot.
  • Converting currency on the fly. Store the rate used at submission. Otherwise an RFQ can cross a threshold overnight because the exchange rate moved.
  • Letting the UI decide the step. The client can send "approve step 2" while step 1 is open. The server must pick the lowest open step itself.
  • Self-approval through a second role. A requester who is also a department head should still not approve their own RFQ. Check identity, not just role.

Summary

A maintainable RFQ approval flow comes down to four ideas:

  1. Rules are data (approval_rules), not if statements.
  2. Each RFQ gets a materialized checklist (rfq_approvals) at submission time.
  3. Approvals run inside a transaction with a row lock, always against the lowest open step.
  4. A price change resets the flow, and every approval records the amount it approved.

None of this needs a workflow engine. An enum, two tables, and two small action classes cover most procurement teams I have seen.

How do you handle approval rules in your apps: rows in a table, a config file, or a full workflow engine?

Top comments (0)